Praktyczne planowanie ścieżkiProgramownie A.I. Gier

Praktyczne planowanie ścieżki

W części 5 zobaczyłeś, jak agenci mogą wykorzystywać wykresy nawigacyjne do planowania ścieżek między lokalizacjami w środowisku. Jednak jeśli chodzi o wdrożenie tej teorii w praktyce, przekonasz się, że istnieje wiele różnych problemów do rozwiązania, zanim agenci zaczną się przemieszczać tak, jak chcesz. Tu omówimu=y wiele praktycznych problemów napotkanych podczas projektowania modułów planowania ścieżek agentów gier. Chociaż wersje demonstracyjne w tym rozdziale są oparte na frameworku Raven, większość technik ma zastosowanie w wielu różnych gatunkach gier.

Budowa wykresu nawigacyjnego

Aby wyznaczyć ścieżkę od A do B za pomocą algorytmu wyszukiwania, środowisko gry musi zostać podzielone na struktury danych, które algorytmy mogą eksplorować: wykres nawigacyjny. Ponieważ istnieje wiele sposobów reprezentowania geometrii, która składa się na świat gry - na przykład na podstawie kafelków, wektorów lub wielokątów - trudno się dziwić, że istnieje wiele metod przekształcania istotnych informacji przestrzennych w strukturę danych podobną do wykresu. Przeanalizujmy kilka popularnych metod stosowanych we współczesnych grach.

Na podstawie kafelków

Gry oparte na kafelkach lub komórkach, takie jak gry obficie występujące w gatunkach RTS i gier wojennych, mają duże, a czasem złożone środowisko oparte na kwadratach lub heksach. Dlatego sensowne jest zaprojektowanie wykresu nawigacyjnego gry wokół tych komórek: każdy węzeł wykresu reprezentuje środek komórki, a krawędzie wykresu oznaczają połączenia między sąsiednimi komórkami. W grach tego typu od czasu do czasu manewrowanie jednostką gry - jak czołg lub żołnierz - będzie się wiązać z różnymi rodzajami terenu. Woda i błoto są przecież o wiele trudniejsze do pokonania przez zbiornik Shermana niż asfalt lub ubita ziemia. Ponieważ każda komórka zwykle przypisuje określony typ terenu przez projektanta mapy, używanie tej informacji do ważenia krawędzi wykresu nawigacyjnego jest trywialne. Algorytmy zastosowane do przeszukiwania wykresu mogą wykorzystać te dodatkowe informacje do ustalenia odpowiednich ścieżek przez teren, takich, które unikają wody i błota lub omijają wzgórza, a nie ich wierzchołki. Wadą używania komórek jako szkieletu dla grafu nawigacyjnego jest to, że przestrzenie wyszukiwania mogą szybko stać się bardzo duże. Nawet skromna mapa komórkowa 100 x 100 będzie wymagała wykresu złożonego z maksymalnie 10 000 węzłów i 78 000 (lub więcej) krawędzi. Biorąc pod uwagę, że gry RTS zwykle mają jednocześnie kilkadziesiąt, a nawet setki jednostek AI, przy czym wiele z nich żąda przeszukiwania wykresów na każdym etapie aktualizacji, to cholernie dużo do zrobienia, nie wspominając o związanych z tym kosztach pamięci . Na szczęście dostępnych jest wiele metod zmniejszania obciążenia, które omówimy w dalszej części

Punkty widoczności

Wykres nawigacyjny punktów widoczności (POV) jest tworzony przez umieszczenie węzłów wykresu, zwykle ręcznie, w ważnych punktach środowiska, tak aby każdy węzeł wykresu miał linię widzenia co najmniej jednego innego. Umieszczone ostrożnie węzły wykresu utworzą wykres łączący wszystkie ważne obszary w geometrii świata. Rysunek poniższt pokazuje prosty wykres POV utworzony dla mapy Raven (Raven_DM1).



Jedną z cech wykresów POV jest to, że można je łatwo rozszerzyć, aby zawierały węzły, które podają informacje ponad danymi dotyczącymi łączności. Na przykład, węzły można łatwo dodać do wykresu POV, aby reprezentować dobre pozycje snajperskie, osłonowe lub zasadzkowe. Minusem jest to, że jeśli mapa gry jest duża i złożona, projektant mapy może poświęcić bardzo dużo cennego czasu na opracowanie pozycji i ulepszenie wykresu. Wykres POV może być również problematyczny, jeśli planujesz dołączyć dowolny rodzaj funkcji generowania mapy, ponieważ musisz także opracować zautomatyzowaną metodę generowania struktury wykresu POV, aby nowe mapy były przydatne. (Dlatego niektóre gry nie mają funkcji losowego generowania map.) Jednym z rozwiązań tego problemu jest jednak stosowanie technik rozszerzonej geometrii.

Rozszerzona geometria

Jeśli środowisko gry jest zbudowane z wielokątów, można wykorzystać informacje zawarte w tych kształtach, aby automatycznie utworzyć wykres POV, który w przypadku dużych map może być oszczędnością czasu. Osiąga się to najpierw poprzez rozszerzenie wielokątów o wielkość proporcjonalną do promienia ograniczającego agentów gry. Patrz rysunki A i B.



Wierzchołki definiujące tę rozszerzoną geometrię są następnie dodawane jako węzły do wykresu nawigacyjnego. Na koniec uruchamiany jest algorytm w celu sprawdzenia linii wzroku między wierzchołkami, a krawędzie są odpowiednio dodawane do wykresu. Rysunek C pokazuje gotowy wykres nawigacyjny. Ponieważ wielokąty są powiększane o kwotę nie mniejszą niż agent w promieniu ograniczającym agent może przeszukiwać wynikowy wykres nawigacyjny, aby tworzyć ścieżki, które bezpiecznie negocjują otoczenie bez wpadania na ściany.

NavMesh

Jednym z podejść, które zyskuje na popularności wśród twórców gier, jest wykorzystanie sieci wypukłych wielokątów, zwanych navmesh, w celu opisania dostępnych obszarów gry. Wypukły wielokąt ma tę cenną właściwość, że umożliwia swobodne przemieszczanie się z dowolnego punktu wielokąta do dowolnego innego. Jest to przydatne, ponieważ umożliwia reprezentację środowiska za pomocą wykresu, na którym każdy węzeł reprezentuje przestrzeń wypukłą (zamiast punktu). Rysunek pokazuje mapę z rysunku pierwszego *podzieloną na partycje w taki sposób.



Dlaczego to dobra rzecz? Cóż, navmeshes są wydajne. Struktura danych wymagana do przechowywania jednej jest zwarta i można ją bardzo szybko przeszukać. Ponadto tam, gdzie środowiska zbudowane są w całości z wielokątów - podobnie jak większość gier typu 3D FPS - możliwe jest użycie algorytmów do automatycznego podziału obszarów dostępnych na mapach.

Wykres nawigacyjny Raven

Ponieważ dają mi największą możliwość wykazania różnorodnego zakresu technik, wykresy nawigacyjne dla map Raven są tworzone przy użyciu metody POV. Widziałeś wcześniej, w jaki sposób nawigator pokazany na rysunku pierwszym został utworzony przez ręczne pozycjonowanie węzłów w edytorze map. W tym przykładzie niewielka liczba węzłów została umieszczona na ważnych skrzyżowaniach. Ponieważ każdy węzeł skutecznie reprezentuje duży obszar przestrzenny, można powiedzieć, że ten typ wykresu jest gruboziarnisty (lub ziarnisty). Gruboziarniste wykresy są bardzo zwartymi strukturami danych. Używają bardzo mało pamięci i są szybkie do wyszukiwania i stosunkowo łatwe do utworzenia, chociaż mają kilka ograniczeń. Rzućmy okiem na niektóre z ich wad.

Gruboziarniste wykresy

Jeśli gra ogranicza tylko agentów do poruszania się wzdłuż krawędzi wykresu nawigacyjnego, takich jak ruch postaci w gamie gier Pac-Man, wówczas gruboziarnisty navgraph to świetny wybór. Jeśli jednak projektujesz grafik dla gry, w której postacie mają większą swobodę, zgrubne wykresy mogą być zwiastunem różnego rodzaju problemów. Na przykład większość gier RTS / RPG korzysta z systemu sterowania, w którym użytkownik może rozkazywać postaciom, aby przejść do dowolnego nawigowanego obszaru mapy. Zwykle odbywa się to za pomocą kilku kliknięć myszką, jednego, aby wybrać NPC, a drugiego, aby wybrać pozycję, w której powinien się przenieść. Aby przenieść NPC do pozycji AI musi wykonać następujące kroki:

1. Znajdź najbliższy widoczny węzeł wykresu do bieżącej lokalizacji NPC, powiedzmy, węzeł A.
2. Znajdź najbliższy widoczny węzeł wykresu do docelowej lokalizacji, powiedzmy, węzeł B.
3. Użyj algorytmu wyszukiwania, aby znaleźć ścieżkę o najniższych kosztach od A do B.
4. Przenieś NPC do węzła A.

5. Przesuń NPC wzdłuż ścieżki obliczonej w kroku 3.
6. Przenieś NPC z B do miejsca docelowego.
Jeśli po tych krokach pojawi się gruboziarnisty wykres, taki jak pokazany wcześniej, nieestetyczne ścieżki będą się regularnie pojawiać. Niektóre z tych załamań można usunąć za pomocą algorytmu wygładzania ścieżki, takiego jak ten omówiony w dalszej części tego rozdziału, ale ze względu na zgrubność wykresu nawigacyjnego nadal będzie wiele sytuacji, w których agent zygzakuje nienaturalnie na początku i na końcu punkty ścieżki. Co gorsza, w przypadku korzystania z gruboziarnistych wykresów prawie zawsze istnieje kilka pozycji na mapie, do których nie ma linii wzroku z żadnego z węzłów wykresu, co skutecznie czyni te obszary niewidocznymi dla dowolnego planisty ścieżki. Rysunek ilustruje dwie pozycje na mapie Raven_DM1, które są niedostępne dla narzędzia planowania ścieżki.



Te "niewidoczne" obszary są dość łatwe do wykrycia podczas testowania małej mapy, ale są znacznie trudniejsze do znalezienia wraz ze wzrostem złożoności mapy. Znajduje to odzwierciedlenie w wielu grach, które zostały wydane z takimi problemami. Możesz zaobserwować te problemy z pierwszej ręki, uruchamiając plik wykonywalny Raven_CoarseGraph. Po uruchomieniu demonstracji bot zbada środowisko, tworząc ścieżki do losowo wybranych węzłów graficznych. Kliknij bota prawym przyciskiem myszy, aby go wybrać, a zobaczysz ścieżkę, którą podąża, pokazaną jako serię czerwonych kropek. Zwróć uwagę, jak ruch bota wygląda dobrze, o ile utrzymuje się na pozycjach na wykresie nawigacyjnym. Teraz ponownie kliknij prawym przyciskiem myszy bota, aby go "posiąść". Po zdobyciu możesz kliknąć prawym przyciskiem myszy w dowolnym miejscu w środowisku, a bot spróbuje obliczyć ścieżkę do tego punktu (o ile punkt znajduje się w obszarze żeglownym mapy). Obserwuj, jak bot musi cofać się, aby podążać pewnymi ścieżkami.

Drobnoziarniste wykresy

Złe ścieżki i niedostępne pozycje można poprawić, zwiększając ziarnistość wykresu nawigacyjnego. Rysunek poniżej to przykład bardzo drobno granulowanego wykresu utworzonego dla mapy Raven_DM1.



Ręczne tworzenie takiego wykresu jest niezwykle żmudne, więc do wykonania większości zadań edytor map wykorzystuje algorytm wypełniania zalania. Więcej informacji znajduje się na poniższym pasku bocznym. Ponieważ drobnoziarniste wykresy są podobne w topologii do grafik nawigacyjnych opartych na kafelkach - i dlatego stanowią podobne wyzwania dla programisty AI - wykorzystam je jako podstawę do zademonstrowania technik opisanych w pozostałej części. W ten sposób mam nadzieję zabić kilka ptaków jednym kamieniem, a pod koniec zrozumiesz, jak stworzyć agenta zdolnego do planowania ścieżek w dowolnym środowisku gry, czy to FPS, RTS, czy RPG.

Używanie algorytmu Flood Fill do stworzenia wykresu nawigacyjnego

Aby użyć algorytmu wypełniania zalania do utworzenia wykresu nawigacyjnego, najpierw gdzieś na mapie umieszczany jest pojedynczy węzeł "zarodkowy". Patrz rysunek poniżej, u góry w lewo. Algorytm "powiększa" wykres, rozszerzając węzły i krawędzie na zewnątrz z nasion w każdym dostępnym kierunku, a następnie z węzłów na skraju wykresu, aż do wypełnienia całego obszaru żeglownego. Rysunek pokazuje pierwsze sześć iteracji takiego procesu.



Jest to podobny rodzaj techniki malowania używanej przez programy do wypełniania nieregularnych kształtów, z wyjątkiem tego, że zamiast zalewać kształt kolorem, edytor wykorzystuje algorytm do zalania mapy węzłami graficznymi i krawędziami. Poszczególne węzły mogą być następnie przenoszone, usuwane lub dodawane przez projektanta w celu uzyskania pożądanego rezultatu. Aby zapewnić nieograniczony ruch agenta, podczas procesu algorytm zapewnia, że wszystkie węzły i krawędzie są ustawione w minimalnej odległości równej promieniu ograniczającemu agenta od ścian.

Dodawanie przedmiotów do wykresu nawigacji Raven

Większość gier zawiera przedmioty, które agent może w jakiś sposób odebrać i wykorzystać. Te elementy można dodawać jako węzły do wykresu nawigacyjnego, umożliwiając sztucznej inteligencji planowania ścieżki łatwe wyszukiwanie elementów i planowanie ścieżek do nich. Tam, gdzie jest wiele instancji tego samego przedmiotu, sztuczna inteligencja może użyć nawigatora, aby szybko ustalić, który jest najmniej kosztowny do osiągnięcia. Pamiętacie, jak w rozdziale 5 pokazałem wam przykład klasy węzłów grafowych zaprojektowanej specjalnie do użytku z grafami nawigacyjnymi? Na wypadek gdyby twoja pamięć była tak słaba jak moja, oto znowu lista:

template < class extra_info = void* >
class NavGraphNode : public GraphNode
{
protected:
// pozycja węzła
Vector2D m_vPosition;
// często chcesz, aby węzeł navgraph zawierał dodatkowe informacje.
// (na przykład węzeł może reprezentować pozycję typu elementu
// takie jak zdrowie, umożliwiając w ten sposób algorytmowi wyszukiwania przeszukanie wykresu // dla tego typu elementu)
extra_info m_ExtraInfo;
public:
//ctors
NavGraphNode():m_ExtraInfo(extra_info()){}
NavGraphNode(int idx,
Vector2D pos):GraphNode(idx),
m_vPosition(pos),
m_ExtraInfo(extra_info())
{}
virtual ~NavGraphNode(){}
Vector2D Pos()const;
void SetPos(Vector2D NewPosition);
extra_info ExtraInfo()const;
void SetExtraInfo(extra_info info);
/* DODATKOWE SZCZEGÓŁY POMINIĘTE */
};

Jest to klasa węzłów używana przez wykres nawigacyjny Raven. Jak wspomniano wcześniej , typy przedmiotów w Raven pochodzą z klasy Trigger. Po dodaniu wyzwalacza do mapy za pomocą edytora map dodawany jest również węzeł wykresu. Członek m_ExtraInfo tego węzła ma przypisany wskaźnik do elementu, z którym jest połączony, umożliwiając w ten sposób zmodyfikowanemu algorytmowi wyszukiwania zapytanie o wykres nawigacji dla określonych typów elementów, a także dla określonych indeksów węzłów.

WSKAZÓWKA. Projektując mapy niektórych gier, dobrze jest umieścić często używane przedmioty, takie jak amunicja i zbroja, bezpośrednio na najczęściej używanych ścieżkach agentów gry. Pomaga to agentom, ponieważ skupiają się na ważniejszych celach gry, zamiast biegać w poszukiwaniu broni i amunicji.

Korzystanie z partycjonowania przestrzennego w celu przyspieszenia zapytań o bliskość

Jedną z najczęściej używanych metod klasy planowania ścieżki jest funkcja, która określa najbliższy widoczny węzeł dla danej pozycji. Jeśli to wyszukiwanie zostanie przeprowadzone przez iterację przez wszystkie węzły w celu znalezienia najbliższego, wydajność nastąpi w czasie O (n2): Za każdym razem, gdy liczba węzłów się podwoi, czas potrzebny na ich przeszukanie zwiększa się czterokrotnie. Jak widzieliśmy w rozdziale 3, skuteczność takich wyszukiwań można poprawić, stosując technikę podziału przestrzennego, taką jak podział na komórki, drzewa BSP, drzewa quad lub dowolną z wielu dostępnych metod. W przypadku wykresów nawigacyjnych z kilkuset węzłów podział przestrzenny daje radykalny wzrost prędkości, ponieważ czas wyszukiwania staje się funkcją gęstości węzłów, O (d), a nie liczbę węzłów; i od gęstości węzłów na całym obrazie nawigacyjnym wydaje się być dość spójny, czas potrzebny na wykonanie zapytania o bliskość węzła będzie stały. W związku z tym klasa Raven_Game dzieli na partycje węzły navgraph przy użyciu metody przestrzeni komórkowej po załadowaniu mapy.

UWAGA. Nie ma napisanego kodu dla tej części jako takiej. Wszystkie demonstracje zostały utworzone przez kompilację plików projektu Raven z włączonymi lub wyłączonymi niektórymi opcjami, aby zademonstrować każdą omawianą technikę. Z tego powodu dema używają skompilowanych plików skryptowych Lua, aby zapobiec manipulowaniu przy opcjach, które mogą spowodować awarię demonstracji. Aby uzyskać pełne prawa do kręcenia się, skompiluj właściwy projekt Raven!

Tworzenie klasy PathPlanner

Większość pozostałej części tekstu zostanie wydana po opracowaniu klasy planowania ścieżki zdolnej do wykonywania i zarządzania licznymi żądaniami wyszukiwania grafów wymaganych przez bota Raven. Ta klasa nazywa się Raven_PathPlanner i każdy bot będzie miał jej instancję. Klasa zacznie się w prosty sposób, ale w miarę postępów jej możliwości będą stopniowo rozszerzane, dając możliwość zademonstrowania, jak rozwiązać wiele typowych problemów napotkanych podczas opracowywania sztucznej inteligencji planowania ścieżki. Najpierw rozważmy minimalną funkcjonalność, jaką musi zapewnić obiekt planowania ścieżki. Bot Raven przynajmniej powinien być w stanie zaplanować ścieżkę z aktualnej pozycji do dowolnej innej lokalizacji, biorąc pod uwagę, że obie pozycje są prawidłowe i możliwe do nawigacji, a ścieżka jest możliwa. Bot Raven powinien także być w stanie zaplanować ścieżkę o najniższym koszcie między swoją obecną pozycją a określonym typem przedmiotu, takim jak pakiet zdrowia. W rezultacie klasa planowania ścieżki musi mieć metody przeszukiwania navgraph w poszukiwaniu takich ścieżek i uzyskiwania dostępu do wynikowych danych ścieżki. Mając na uwadze te funkcje, spróbujmy najpierw w klasie planowania ścieżki.

class Raven_PathPlanner
{
private:
// dla czytelności
enum {no_closest_node_found = -1};
private:
// Wskaźnik do właściciela tej klasy
Raven_Bot* m_pOwner;
// lokalne odniesienie do navgraph
const Raven_Map::NavGraph& m_NavGraph;
//to jest pozycja, którą bot chce zaplanować ścieżkę do osiągnięcia
Vector2D m_vDestinationPos;
// zwraca indeks najbliższego widocznego i niezakłóconego węzła graficznego do
// podana pozycja. Jeśli nie zostanie znaleziony, zwraca wyliczoną wartość
// "no_closest_node_found"
int GetClosestNodeToPosition(Vector2D pos)const;
public:
Raven_PathPlanner(Raven_Bot* owner);
// znajduje ścieżkę o najniższym koszcie między pozycją agenta a celem
//pozycja. Wypełnia ścieżkę listą punktów, jeśli wyszukiwanie zakończy się powodzeniem
// i zwraca true. Zwraca false, jeśli nie powiedzie się bool CreatePathToPosition(Vector2D TargetPos, std::list& path);
// znajduje ścieżkę o najniższym koszcie do wystąpienia klasy ItemType. Wypełnia ścieżkę znakiem // lista punktów, jeśli wyszukiwanie zakończy się powodzeniem i zwróci wartość true. Zwraca
// false, jeśli się nie powiedzie bool CreatePathToItem(unsigned int ItemType, std::list& path);
};

Ta klasa zapewnia minimalną funkcjonalność wymaganą przez agenta gry. Przyjrzyjmy się bliżej metodom tworzącym ścieżki.

Planowanie ścieżki do pozycji

Planowanie ścieżki z bieżącej lokalizacji bota do miejsca docelowego jest proste. Planista ścieżki musi:
1. Znajdź najbliższy widoczny niezakłócony węzeł grafowy do bieżącej lokalizacji bota.
2. Znajdź najbliższy widoczny niezakłócony węzeł wykresu do lokalizacji docelowej.
3. Użyj algorytmu wyszukiwania, aby znaleźć ścieżkę najmniejszych kosztów między nimi.

Poniższy kod używa tych kroków. Komentarze powinny być odpowiednim wyjaśnieniem.

bool Raven_PathPlanner::CreatePathToPosition(Vector2D TargetPos,
std::list& path)
{
// zanotuj pozycję docelową
m_vDestinationPos = TargetPos;
// jeśli cel nie jest zasłonięty od pozycji bota, ścieżka nie potrzebuje
// do obliczenia, a bot może PRZYBYĆ bezpośrednio w miejscu docelowym.
// isPathObstructed to metoda, która zaczyna się od początku // pozycja, pozycja docelowa i promień encji i określa, czy
// agent tego rozmiaru jest w stanie poruszać się bez przeszkód między dwiema pozycjami.
// Służy tutaj do ustalenia, czy bot może przejść bezpośrednio do celu
// lokalizacja bez potrzeby planowania ścieżki.
if (!m_pOwner()->GetWorld()->isPathObstructed(m_pOwner->Pos(),
TargetPos,
m_pOwner->BRadius()))
{
path.push_back(TargetPos);
return true;
}
// znajdź najbliższy niezakłócony węzeł w pozycji bota.
// GetClosestNodeToPosition to metoda odpytująca wykres nawigacyjny
// węzły (przez partycję przestrzeń komórkowa), aby określić najbliższą niezakłóconą
// węzeł do podanego wektora pozycji. Służy tutaj do znalezienia najbliższego zdjęcia // niezakłócony węzeł do bieżącej lokalizacji bota.
int ClosestNodeToBot = GetClosestNodeToPosition(m_pOwner->Pos());
// jeśli nie znaleziono widocznego węzła, zwróć błąd. Nastąpi to, jeśli
// navgraph jest źle zaprojektowany lub jeśli botowi udało się zdobyć // * wewnątrz * geometrii (otoczonej ścianami) lub przeszkody.
if (ClosestNodeToBot == no_closest_node_found)
{
return false;
}
// znajdź najbliższy widoczny niezakłócony węzeł do pozycji docelowej
int ClosestNodeToTarget = GetClosestNodeToPosition(TargetPos);
// zwrócenie błędu, jeśli występuje problem ze zlokalizowaniem widocznego węzła z poziomu //cel.
// Tego rodzaju rzeczy występują znacznie częściej niż powyższe. Dla
// przykład, jeśli użytkownik kliknie wewnątrz obszaru ograniczonego ścianami lub wewnątrz zdjęcia //obiekt.
if (ClosestNodeToTarget == no_closest_node_found)
{
return false;
}
// utwórz instancję klasy wyszukiwania A *, aby wyszukać ścieżkę między
// najbliższy węzeł do bota i najbliższy węzeł do pozycji docelowej. To
// Wyszukiwanie * będzie wykorzystywać heurystyczną linię euklidesową
typedef Graph_SearchAStar< Raven_Map::NavGraph, Heuristic_Euclid> AStar;
AStar search(m_NavGraph,
ClosestNodeToBot,
ClosestNodeToTarget);
// złap ścieżkę
std::list PathOfNodeIndices = search.GetPathToTarget();
// jeśli wyszukiwanie się powiedzie, zamień indeksy węzłów na wektory pozycji
if (!PathOfNodeIndices.empty())
{
ConvertIndicesToVectors(PathOfNodeIndices, path);
// pamiętaj, aby dodać pozycję docelową na końcu ścieżki
path.push_back(TargetPos);
return true;
}
else
{
// nie znaleziono ścieżki podczas wyszukiwania
return false;
}
}

Planowanie ścieżki do typu elementu

Jest lepszym algorytmem do wyszukiwania ścieżki o najniższym koszcie od aktualnej pozycji bota do konkretnej pozycji docelowej, ale co z tym, kiedy wymagana jest ścieżka o najniższych kosztach do typu przedmiotu - takiego jak wyrzutnia rakiet - gdzie może być wiele wystąpienia w środowisku określonego typu? Aby obliczyć koszt heurystyczny podczas wyszukiwania A*, algorytm musi mieć zarówno pozycję źródłową, jak i docelową. W związku z tym, używając A* do wyszukiwania najbliższej instancji typu przedmiotu, wyszukiwanie musi zostać zakończone dla każdej instancji obecnej w świecie gry, zanim ta o najniższej cenie będzie mogła zostać wybrana jako najlepsza pozycja do przejścia. Jest to w porządku, jeśli twoja mapa zawiera tylko garść instancji przedmiotu, ale co, jeśli zawiera wiele? W końcu środowiska gier RTS często zawierają dziesiątki, a nawet setki instancji zasobów, takich jak drzewa lub złoto. Oznacza to, że konieczne będzie przeprowadzenie wielu wyszukiwań A* w celu zlokalizowania tylko jednego elementu. To nie jest dobre. Gdy występuje wiele podobnych typów przedmiotów, algorytm Dijkstry jest lepszym wyborem. Jak się dowiedziałeś, algorytm Dijkstry "wyrasta" najkrótszą ścieżką na zewnątrz od węzła głównego do momentu osiągnięcia celu lub zbadania całego wykresu. Gdy tylko szukany element zostanie znaleziony, algorytm zakończy się, a SPT będzie zawierać ścieżkę od katalogu głównego do najbliższego elementu pożądanego typu. Innymi słowy, bez względu na to, ile wystąpień typu przedmiotu występuje w świecie gry, algorytm Dijkstry musi zostać uruchomiony tylko raz, aby znaleźć ścieżkę najmniejszego kosztu do jednego z nich. W obecnej postaci klasa algorytmów Dijkstry używana do tej pory w ta książka zakończy się dopiero po znalezieniu określonego indeksu węzłów. W rezultacie kod musi zostać zmieniony, aby wyszukiwanie zakończyło się po lokalizacji aktywnego typu elementu (wyzwalacza). Można to łatwo osiągnąć, określając jako parametr szablonu polityka stanowi warunek zakończenia. Na przykład:

template < class graph_type, class termination_condition >
class Graph_SearchDijkstra
{
/* POMINIĘTE */
};
Polityka warunków zakończenia jest klasą zawierającą pojedynczą metodę statyczną isSatisfied, która zwraca wartość true, jeśli warunki wymagane do zakończenia są spełnione. Podpis isSatisfied wygląda następująco:

static bool isSatisfied (const graph_type & G, int target, int CurrentNodeIdx);

Zmodyfikowany algorytm Dijkstry może korzystać z takich zasad, aby określić, kiedy należy zakończyć wyszukiwanie. Aby to ułatwić, linia:

// jeśli cel został znaleziony, wyjdź
if (NextClosestNode == m_iTarget) return;
found in Graph_SearchDijkstra::Search is replaced with:
// jeśli cel został znaleziony, wyjdź
if (termination_condition::isSatisfied(m_Graph,
m_iTarget,
NextClosestNode))
{
// zanotuj indeks węzła, który spełnił warunek. To
// jest tak, że możemy pracować wstecz od indeksu, aby wyodrębnić ścieżkę z
// najkrótsza ścieżka.
m_iTarget = NextClosestNode;
return;
}

Jednak zanim ten dostosowany algorytm będzie mógł być użyty, należy stworzyć odpowiednią politykę warunków zakończenia. W Raven typy przedmiotów są reprezentowane przez wyzwalacze dawców. Dlatego podczas wyszukiwania typu elementu wyszukiwanie powinno zakończyć się, gdy zostanie zlokalizowany węzeł wykresu, którego pole m_ExtraInfo wskazuje na aktywny wyzwalacz odpowiedniego typu. Oto klasa zasad warunku zakończenia, która kończy wyszukiwanie na podstawie tych kryteriów:

template < class trigger_type >
class FindActiveTrigger
{
public:
template
static bool isSatisfied(const graph_type& G, int target, int CurrentNodeIdx)
{
bool bSatisfied = false;
// uzyskać odwołanie do węzła o podanym indeksie węzłów
const graph_type::NodeType& node = G.GetNode(CurrentNodeIdx);
// jeśli dodatkowe pole informacyjne wskazuje na wyzwalacz dawcy, sprawdź, aby się upewnić, że
// jest aktywny i że jest poprawnego typu.
if ((node.ExtraInfo() != NULL) &&
node.ExtraInfo()->isActive() &&
(node.ExtraInfo()->EntityType() == target) )
{
bSatisfied = true;
}
return bSatisfied;
}
};

Uzbrojeni w ten warunek zakończenia i dostosowany algorytm wyszukiwania Dijkstra łatwo jest znaleźć ścieżkę o najniższym koszcie do aktywnego elementu określonego typu. Powiedzmy, że chcesz znaleźć najbliższą paczkę zdrowia dla węzła grafu z indeksem 6. Oto jak:

typedef FindActiveTrigger > term_con;
typedef Graph_SearchDijkstra_TS SearchDij;
// utworzyć wyszukiwanie
SearchDij dij(G, //wykres6, //węzeł źródłowy
type_health); // typ przedmiotu, którego szukamy
// złap ścieżkę
std::list path = dij.GetPathToTarget();
gdzie type_health jest wartością wyliczoną

UWAGA 3D. Mam nadzieję, że rozumiesz, że nie ma różnicy między wyszukiwaniem ścieżek w 3D a wyszukiwaniem ścieżek w 2D. Oczywiście, aby agent mógł poruszać się w większości środowisk 3D, musiałby wykonywać takie czynności jak skakanie wąwozami i korzystanie z wind, ale te rozważania powinny być przejrzyste dla planisty ścieżki. Po prostu manifestują się jako korekty kosztów brzegowych, dzięki czemu algorytm wyszukiwania może uwzględnić koszt wykonania skoku, przejścia przez półkę, użycia windy lub zrobienia czegokolwiek, gdy szuka ścieżki o najniższym koszcie do pozycji docelowej. Jeśli nadal nie jest to oczywiste, zdecydowanie zalecamy cofnięcie się i ponowne przeczytanie części 5, pamiętając, że wykres może istnieć w dowolnej liczbie wymiarów.

Ścieżki jako węzły czy ścieżki jako krawędzie?

Do tej pory myśleliśmy o ścieżkach jako serii wektorów pozycji lub punktów trasy. Często ścieżki składające się z krawędzi wykresu zapewniają programistom AI dodatkową elastyczność. Jako przykład weźmy grę z postaciami niezależnymi, w których ruch między punktami w otoczeniu musi być ograniczony do określonego typu, np. "Palcami tutaj", "czołgać się tutaj" lub "biegać szybko tutaj". Możesz pomyśleć, że odpowiednie węzły navgraph gry mogą być opatrzone adnotacjami z flagami wskazującymi pożądane zachowanie (na przykład, węzeł może być oznaczony zachowaniem "palców", aby agent zaczął palcami, jak tylko dotrze do tego węzła), ale w ćwiczyć, istnieją problemy z tym podejściem. Na przykład ryc. 8.10 pokazuje część wykresu nawigacyjnego, w którym jedna z krawędzi A - B przecina rzekę.



Projekt gry wymaga, aby agenci zmienili się na zachowanie "pływania" podczas podróży z A do B (lub odwrotnie), więc węzły A i B są opatrzone adnotacjami, aby to odzwierciedlić. Powiedzmy, że agent podąża ścieżką e - A - B - h. Gdy agent dotrze do węzła A, jego zachowanie zmieni się na pływanie i może bezpiecznie przekroczyć krawędź do B. Jak dotąd tak dobrze, ale niestety w tym momencie ma problemy. Kiedy osiągnie węzeł B, który również jest opatrzony adnotacją o zachowaniu podczas pływania, będzie kontynuował pływanie wzdłuż krawędzi B - h. Niedobrze. Jeśli to nie wystarczy, powiedzmy, że agent chce podążać ścieżką e - A - c. Jak tylko osiągnie A, zacznie pływać, mimo że nie zamierza przekraczać rzeki!

Problem ten można jednak łatwo rozwiązać, jeśli krawędzie wykresu są opatrzone adnotacjami zamiast węzłów. W ten sposób agent może łatwo zapytać o informacje brzegowe, podążając ścieżką i odpowiednio zmienić zachowanie. Biorąc pod uwagę poprzedni przykład, oznacza to, że krawędź A - B jest opatrzona instrukcją pływania, a wszystkie pozostałe krawędzie instrukcją chodzenia (lub cokolwiek innego, co może być odpowiednie). Teraz, gdy agent podąży ścieżką e - A - B - h, jego ruch będzie prawidłowy.
v WSKAZÓWKA. Za pomocą adnotacji można łatwo określić zachowanie krawędzi, które jest modyfikowane podczas gry. Na przykład, możesz zaprojektować mapę, która ma prowizoryczny most - jak spadający kłód - przechodzący przez strumień, który agenci przechodzą normalnie, dopóki most nie zostanie zniszczony lub przeniesiony. Po zdjęciu mostu adnotacja na krawędzi zmienia się na "pływanie", a jej koszt wzrasta, aby odzwierciedlić dodatkową ilość czasu potrzebną do poruszania się wzdłuż niej. W ten sposób agenci nadal biorą pod uwagę krawędź podczas planowania ścieżek i odpowiednio zmodyfikują swoją animację, gdy ją przechodzą. (Można nawet usunąć / wyłączyć krawędź, aby przedstawić warunki uniemożliwiające przepływ strumienia, takie jak powódź).

Adnotowany przykład klasy krawędzi

Adnotowaną krawędź można łatwo utworzyć, wywodząc z GraphEdge i dodając dodatkowy element danych do reprezentowania flagi (lub flag, w zależności od tego, jakie informacje chcesz reprezentować krawędź). Oto przykład:

class NavGraphEdge : public GraphEdge
{
public:
// wyliczyć niektóre flagi zachowania
enum BehaviorType
{
normal = 1 << 0,
tippy_toe = 1 << 1,
swim = 1 << 2,
crawl = 1 << 3,
creep = 1 << 4
};
protected:
// zachowanie związane z przechodzeniem przez tę krawędź
BehaviorType m_iBehavior;
/* DODATKOWE SZCZEGÓŁY POMINIĘTE */
};

WSKAZÓWKA. Jeśli twój projekt gry wymaga adnotacji krawędzi i / lub węzła, często okaże się, że dodatkowe pola w klasach węzłów / krawędzi są nieużywane (lub ustawione na "normalne") w większości przypadków w grafie nawigacyjnym. Może to być znaczne marnotrawstwo pamięci, jeśli wykres jest duży. W takich przypadkach zalecam użycie tabeli odnośników typu mapy skrótów lub, w przypadku dużej ilości adnotacji na instancję, utworzenie specjalnej struktury danych, do której każda krawędź lub węzeł może przechowywać wskaźnik.

Modyfikowanie klasy Planowanie ścieżki w celu dostosowania krawędzi z adnotacjami

Aby uwzględnić adnotacje na krawędziach, należy zmodyfikować klasy planera ścieżki i algorytmu wyszukiwania, aby zwracały ścieżki zawierające dodatkowe informacje. Aby to ułatwić, Raven korzysta z klasy PathEdge - prostej struktury danych, która przechowuje informacje o położeniu węzła i adnotacjach na krawędzi. Oto jego listing:

class PathEdge
{
private:
// pozycje węzłów źródłowego i docelowego, które łączy ta krawędź
Vector2D m_vSource;
Vector2D m_vDestination;
// zachowanie związane z przechodzeniem przez tę krawędź
int m_iBehavior;
public:
PathEdge(Vector2D Source,
Vector2D Destination,
int Behavior):m_vSource(Source),
m_vDestination(Destination),
m_iBehavior(Behavior)
{}
Vector2D Destination()const;
void SetDestination(Vector2D NewDest);
Vector2D Source()const;
void SetSource(Vector2D NewSource);
int Behavior()const;
};

Metody Raven_PathPlanner :: CreatePath i odpowiadające im algorytmy wyszukiwania zostały nieznacznie zmienione, aby utworzyć std :: list PathEdges. Oto lista zmodyfikowanej metody CreatePathToPosition

bool Raven_PathPlanner::CreatePathToPosition(Vector2D TargetPos,
std::list& path)
{
// jeśli cel nie jest zasłonięty od pozycji bota, ścieżka nie potrzebuje
// do obliczenia, a bot może PRZYBYĆ bezpośrednio w miejscu docelowym.
if (!m_pOwner()->GetWorld()->isPathObstructed(m_pOwner->Pos(),
TargetPos,
m_pOwner->BRadius()))
{
// stwórz krawędź łączącą aktualną pozycję bota i
// pozycja docelowa i wciśnij ją na listę ścieżek (oflagowany, aby użyć
// "normalne" zachowanie)
path.push_back(PathEdge(m_pOwner->Pos(), TargetPos, NavGraphEdge::normal));
return true;
}
// znajdź najbliższy niezakłócony węzeł w pozycji bota.
int ClosestNodeToBot = GetClosestNodeToPosition(m_pOwner->Pos());
if (ClosestNodeToBot == no_closest_node_found)
{
// żadna ścieżka nie jest możliwa
return false;
}
// znajdź najbliższy widoczny niezakłócony węzeł do pozycji docelowej
int ClosestNodeToTarget = GetClosestNodeToPosition(TargetPos);
if (ClosestNodeToTarget == no_closest_node_found)
{
// żadna ścieżka nie jest możliwa
return false;
}
// utwórz instancję klasy wyszukiwania A*.
typedef Graph_SearchAStar AStar;
AStar search(m_NavGraph, ClosestNodeToBot, ClosestNodeToTarget);
// chwyć ścieżkę jako listę PathEdges
path = search.GetPathAsPathEdges();
// jeśli wyszukiwanie zakończyło się pomyślnie, dodaj pierwszą i ostatnią krawędź ręcznie do
// ścieżka
if (!path.empty())
{
path.push_front(PathEdge(m_pOwner->Pos(),
path.front().GetSource(),
NavGraphEdge::normal));
path.push_back(PathEdge(path.back().GetDestination(),
TargetPos,
NavGraphEdge::normal));
return true;
}
else
{
// nie znaleziono ścieżki podczas wyszukiwania
return false;
}
}

Bot może teraz łatwo sprawdzać adnotacje na krawędziach ścieżek i wprowadzać odpowiednie zmiany w zachowaniu. W pseudokodzie za każdym razem, gdy bot wysuwa nową krawędź listy, robi coś takiego:

if (Bot.PathPlanner.CreatePathToPosition(destination, path))
{
PathEdge next = GetNextEdgeFromPath(path)
switch(next.Behavior)
{
case behavior_stealth:
set stealth mode
break
case behavior_swim
set swim mode
break
etc
}
Bot.MoveTo(NavGraph.GetNodePosition(next.To))
}

Od tego momentu możesz założyć, że wszelkie wersje demonstracyjne korzystające ze struktury Raven będą używać ścieżek krawędzi zamiast ścieżek punktów drogi.

WSKAZÓWKA . Niektóre światy gry zawierają teleportery lub "portale", których agenci mogą używać do magicznego i natychmiastowego przemieszczania się między lokalizacjami. Jeśli Twoja gra korzysta z takich urządzeń, nie będziesz w stanie użyć algorytmu wyszukiwania A * do dokładnego planowania ścieżek, ponieważ nie jest możliwe dostosowanie ich do heurystyki. Zamiast tego musisz użyć alternatywnego algorytmu wyszukiwania, takiego jak Dijkstra.

Wygładzanie ścieżki

Dość często, a zwłaszcza jeśli wykres nawigacyjny gry ma kształt siatki, ścieżki utworzone przez planer ścieżek zawierają zwykle niepotrzebne krawędzie, powodując załamania, takie jak te pokazane na rysunku 8.11. Wyglądają nienaturalnie dla ludzkiego oka - w końcu człowiek nie robiłby tak niepotrzebnie takiego zygzaka, więc źle wygląda, gdy robi to agent gry. (Oczywiście jest to całkowicie do przyjęcia, jeśli modelujesz koty domowe, które wydają się mieć swój własny tajny program podczas przejścia z A do B.)



Korzystając z A* i nawigatora opartego na siatce, można tworzyć lepiej wyglądające ścieżki za pomocą heurystycznej odległości na Manhattanie w połączeniu z funkcją, która karze każdą zmianę kierunku. (Pamiętaj, że odległość na Manhattanie to suma liczby kafelków przesuniętych poziomo i pionowo między rozważanymi węzłami.) Jednak wytworzone ścieżki wciąż są dalekie od ideału ze względu na topologię wykresu ograniczającą skręty do przyrostów 45 stopni. Ta metoda również nie działa z innym powszechnym problemem. Jak widzieliśmy, zanim planista ścieżki będzie mógł wyszukać ścieżkę, musi znaleźć węzły wykresu najbliższe pozycjom początkowym i docelowym, i nie zawsze będą to te, które dają naturalnie wyglądającą ścieżkę. Rozwiązanie dla obu tych problemów to przetwarzanie ścieżek końcowych w celu "wygładzenia" niepożądanych załamań. Jest na to kilka metod - jedna zgrubna i jedna precyzyjna.

Wygładzanie ścieżki Szorstkie, ale szybkie

Stosunkowo szybka metoda wygładzania ścieżki polega na sprawdzeniu "przejezdności" między sąsiednimi krawędziami. Jeśli jedna z krawędzi jest zbyteczna, dwie krawędzie są zastępowane jedną.



Algorytm działa w następujący sposób: po pierwsze, dwa iteratory, E1 i E2, są umieszczone odpowiednio na pierwszej i drugiej krawędzi ścieżki. Następnie wykonuje się następujące kroki:

1. Chwyć pozycję źródłową E1.
2. Chwyć pozycję docelową E2.
3. Jeśli agent może poruszać się między tymi dwoma pozycjami bez przeszkód przez geometrię świata, przypisz miejsce docelowe E1 do miejsca docelowego E2 i usuń E2 ze ścieżki. Ponownie przypisz E2 do nowej krawędzi po E1. (Należy pamiętać, że nie jest to prosty test pola widzenia, ponieważ należy wziąć pod uwagę rozmiar bytu - musi on być w stanie poruszać się między dwiema pozycjami bez uderzania w ściany).
4. Jeśli agent nie może poruszać się bez przeszkód między dwiema pozycjami, przypisz E2 do E1 i przejdź do E2.
5. Powtarzaj kroki, aż miejsce docelowe E2 będzie równe miejscu docelowemu ścieżki.

Zobaczmy, jak działa ten algorytm i wygładzamy ścieżkę pokazaną na rysunku 8.13. Najpierw E1 jest skierowany na pierwszą krawędź ścieżki, a E2 na drugą.



E1 jest krawędzią S - 1, a E2 krawędzią 1 - 2. Widzimy, że agent może poruszać się bez przeszkód między E1-> Źródło (S) i E2-> Miejsce docelowe (2), więc pozycja indeksu węzła 2 wynosi przypisany do E1-> Miejsce docelowe, krawędź 1-2 jest usuwana ze ścieżki, a E2 jest wysuwany, aby wskazywać na krawędź 2-3. (Zwróć uwagę, że krawędź wskazywana przez E1 łączy teraz S - 2.)



Po raz kolejny widzimy, że agent jest w stanie poruszać się bez przeszkód między E1-> Źródło (S) i E2-> Miejsce docelowe (3), więc ponownie ścieżka i iteratory są aktualizowane, dając sytuację pokazaną na rysunku



Tym razem jednak pozycje E1-> Źródło (S) i E2-> Miejsce docelowe (4) są zablokowane. Dlatego oba E1 i E2 są zaawansowane o jedną krawędź.



Ponownie, ścieżka między węzłami 3 i 5 jest zablokowana, więc E1 i E2 są zaawansowane. Tym razem, ponieważ ścieżka między 4 a T jest przejezdna, krawędzie są aktualizowane, aby to odzwierciedlić, dając ostateczną wygładzoną ścieżkę pokazaną na rysunku.



Kod źródłowy do wygładzenia ścieżki przy użyciu tego algorytmu wygląda następująco:

void Raven_PathPlanner::SmoothPathEdgesQuick(std::list< PathEdge >& path)
{
// utwórz kilka iteratorów i skieruj je na przód ścieżki
std::list::iterator e1(path.begin()), e2(path.begin());
// przyrost e2, tak aby wskazywał na krawędź po e1.
++e2;
// podczas gdy e2 nie jest ostatnią krawędzią na ścieżce, przejdź przez krawędzie, sprawdzając
// aby sprawdzić, czy agent może poruszać się bez przeszkód z węzła źródłowego
// e1 do węzła docelowego e2. Jeśli agent może poruszać się między nimi // pozycje, wówczas dwie krawędzie są zastępowane jedną krawędzią.
while (e2 != path.end())
{
// sprawdź, czy nie ma przeszkód, odpowiednio wyreguluj i usuń krawędzie
if ( m_pOwner->canWalkBetween(e1->Source(), e2->Destination()) )
{
e1->SetDestination(e2->Destination());
e2 = path.erase(e2);
}
else
{
e1 = e2;
++e2;
}
}
}

Wygładzanie ścieżki Precyzyjne, ale powolne

Niestety, poprzedni algorytm nie jest idealny. Jeśli przyjrzysz się powyższemu rysunkowi , znowu widać, że dwie ostatnie krawędzie można łatwo zastąpić jedną, jak pokazano na rysunku



Algorytm tego nie zauważył, ponieważ sprawdzał tylko przejezdność między sąsiadującymi krawędziami. Bardziej precyzyjny algorytm wygładzania musi iterować wszystkie krawędzie od E1 do krawędzi końcowej za każdym razem, gdy E1 jest przesuwany. Jednak, choć precyzyjna, metoda ta jest znacznie wolniejsza, ponieważ należy wykonać wiele dodatkowych testów skrzyżowań. Oczywiście, jakiego algorytmu wygładzania używasz lub czy w ogóle decydujesz się na wygładzanie, zależy od ilości dostępnego czasu procesora i wymagań twojej gry. Kod bardziej precyzyjnie wygładzający ścieżkę wygląda następująco:

void Raven_PathPlanner::SmoothPathEdgesPrecise(std::list& path)
{
// utwórz kilka iteratorów
std::list::iterator e1, e2;
// wskaż e1 na początek ścieżki
e1 = path.begin();
while (e1 != path.end())
{
// punkt e2 na krawędzi bezpośrednio po e1
e2 = e1;
++e2;
// podczas gdy e2 nie jest ostatnią krawędzią na ścieżce, przejdź przez krawędzie,
// sprawdzanie, czy agent może się poruszać bez przeszkód z poziomu
// węzeł źródłowy e1 do węzła docelowego e2. Jeśli agent może się przenieść // między tymi pozycjami, wówczas wszelkie krawędzie między e1 i e2 są
// zastąpiony jedną krawędzią.
while (e2 != path.end())
{
// sprawdź, czy nie ma przeszkód, odpowiednio wyreguluj i usuń krawędzie
if ( m_pOwner->canWalkBetween(e1->Source(), e2->Destination()) )
{
e1->SetDestination(e2->Destination());
e2 = path.erase(++e1, ++e2);
e1 = e2;
--e1;
}
else
{
++e2;
}
}
++e1;
}
}

Możesz zobaczyć oba te algorytmy w akcji, uruchamiając demo Raven_PathSmoothing.

UWAGA. Jeśli mapa korzysta z krawędzi wykresu opatrzonych specjalnymi zachowaniami lub jeśli agenci mają inne ograniczenia, takie jak ograniczony promień skrętu, algorytmy wygładzania należy zmodyfikować, aby zapobiec usunięciu ważnych krawędzi. Zobacz źródło projektu Raven jako przykład.

Metody zmniejszania obciążenia procesora

Skoki obciążenia mają miejsce, gdy liczba cykli procesora wymaganych przez silnik gry przekracza liczbę dostępnych cykli. Jeśli w grze biegnie mnóstwo agentów kontrolowanych przez AI, wszyscy są w stanie żądać ścieżek w dowolnym momencie, wówczas mogą wystąpić gwałtowne obciążenia, gdy zbyt wielu z nich jednocześnie zażąda wyszukiwania. Gdy tak się stanie, przepływ płynu w grze zostanie przerwany, gdy procesor spróbuje nadążyć za stawianymi mu wymaganiami, tworząc w ten sposób gwałtowny, jąkający się ruch. Oczywiście jest to zła rzecz i należy jej unikać, gdy tylko jest to możliwe. Kolejne strony poświęcone będą metodom, które zmniejszają prawdopodobieństwo skoków obciążenia poprzez zmniejszenie narzutu związanego z aktualizacją żądań planowania ścieżki.

Wstępnie obliczone ścieżki

Jeśli twoje środowisko gry jest statyczne i masz do dyspozycji pamięć, dobrą opcją dla zmniejszenia obciążenia procesora jest użycie wstępnie obliczonych tabel odnośników, umożliwiających bardzo szybkie określenie ścieżek. Można je obliczyć w dowolnym dogodnym czasie, na przykład gdy mapa jest odczytywana z pliku lub tworzona przez edytor map i przechowywana wraz z danymi mapy. Tabela przeglądowa musi zawierać trasy z każdego węzła na wykresie nawigacyjnym do każdego innego węzła na wykresie nawigacyjnym. Można to obliczyć za pomocą algorytmu Dijkstry, aby utworzyć drzewo najkrótszych ścieżek (SPT) dla każdego węzła na wykresie. (Pamiętaj, że SPT jest poddrzewem navgraph zakorzenionym w węźle docelowym, który zawiera najkrótszą ścieżkę do każdego innego węzła.) Informacje są następnie wyodrębniane i przechowywane w dwuwymiarowej tablicy liczb całkowitych. Na przykład, biorąc pod uwagę wykres pokazany na rysunku 8.19, odpowiednia tabela odnośników jest taka, jak pokazano na rysunku





Wpisy w tabeli pokazują następny węzeł, do którego agent powinien przejść na ścieżce od początku do miejsca docelowego. Na przykład, aby określić ścieżkę najmniejszego kosztu od C do E, odsyłamy C do E, dając węzeł B. Węzeł B jest następnie odsyłany do miejsca docelowego, aby dać D, i tak dalej, aż pozycja tabeli będzie równoważna węzeł docelowy. W tym przypadku otrzymujemy ścieżkę C - B - D - E, która jest najkrótszą ścieżką od C do E. Stworzy tabelę wyszukiwania wszystkich par dla dowolnego typu wykresu z interfejsem podobnym do SparseGraph. To wygląda tak

template < class graph_type >
std::vector > CreateAllPairsTable(const graph_type& G)
{
enum {no_path = -1};
// utwórz tabelę 2D elementów ustawionych na wyliczoną wartość no_path
std::vector row(G.NumNodes(), no_path);
std::vector > shortest_paths(G.NumNodes(), row);
for (int source=0; source {
// obliczyć SPT dla tego węzła
Graph_SearchDijkstra search(G, source);
std::vector spt = search.GetSPT();
// teraz, gdy mamy SPT, łatwo jest przeszukać go wstecz, aby znaleźć
// najkrótsze ścieżki z każdego węzła do tego węzła źródłowego
for (int target = 0; target {
if (source == target)
{
shortest_paths[source][target] = target;
}
else
{
int nd = target;
while ((nd != source) && (spt[nd] != 0))
{
shortest_paths[spt[nd]->From][target]= nd;
nd = spt[nd]->From;
}
}
}// następny węzeł docelowy
}// następny węzeł źródłowy
return shortest_paths;
}

Wstępnie obliczone koszty

Czasami agent gry musi obliczyć koszt podróży z jednego miejsca do drugiego. Na przykład, wraz z innymi funkcjami, agent może wziąć pod uwagę koszt przedmiotu gry, decydując, czy chce odebrać ten przedmiot. Wyszukiwanie w celu ustalenia tych kosztów dla każdego typu elementu na każdym etapie aktualizacji AI będzie bardzo kosztowne, jeśli wykres nawigacyjny jest duży i / lub istnieje wiele elementów tego samego rodzaju. W takich sytuacjach tabela wstępnie obliczonych kosztów może okazać się nieoceniona. Jest to skonstruowane w podobny sposób jak tabela tras dla wszystkich par omówiona w ostatniej sekcji, z tym wyjątkiem, że elementy tabeli reprezentują całkowity koszt przejścia najkrótszą drogą z jednego węzła do drugiego. Zobacz rysunek

Kod do utworzenia takiej tabeli jest następujący: r>
template < class graph_type > std::vector > CreateAllPairsCostsTable(const graph_type& G)
{
std::vector row(G.NumNodes(), 0.0);
std::vector > PathCosts(G.NumNodes(), row);
for (int source=0; source {
// przeszukaj
Graph_SearchDijkstra search(G, source);
// iteruj przez każdy węzeł na wykresie i oblicz koszty podróży do
// ten węzeł
for (int target = 0; target {
if (source != target)
{
PathCosts[source][target]= search.GetCostToNode(target);
}
}// następny węzeł docelowy
}// następny węzeł źródłowy
return PathCosts;
}

Planowanie ścieżki w określonym czasie

Alternatywą dla wstępnego obliczania tabel odnośników w celu zmniejszenia obciążenia procesora jest przydzielenie stałej ilości zasobów procesora na krok aktualizacji dla wszystkich żądań wyszukiwania i równomierne rozdzielenie tych zasobów pomiędzy wyszukiwania. Osiąga się to poprzez podzielenie wyszukiwania na wiele etapów czasowych, technikę znaną jako dzielenie czasu. Aby ten pomysł zadziałał, wymagana jest dodatkowa praca, ale w przypadku niektórych gier warto poświęcić ten wysiłek, ponieważ obciążenie procesora związane z wyszukiwaniem wykresów jest stałe, bez względu na to, ilu agentów wysyła żądania.

UWAGA . Chciałbym wyjaśnić, że wyszukiwanie ścieżek czasowych jest przesadą w przypadku gry z garstką agentów, takich jak Raven, ale to świetna technika do rozważenia, czy Twoja gra ma dziesiątki lub setki agentów, a zwłaszcza jeśli tworzysz platformę konsolową, ponieważ pomaga ci żyć w ograniczeniach sprzętowych.

Przede wszystkim wyszukiwania Dijkstra i A* muszą zostać zmodyfikowane w taki sposób, aby mogły przeszukiwać wykres na wielu etapach aktualizacji. Następnie, gdy agenci żądają wyszukiwania, ich planiści ścieżek tworzą wystąpienia odpowiednich wyszukiwań (A * lub Dijkstra) i rejestrują się w klasie menedżera ścieżek. Menedżer ścieżek prowadzi listę wskaźników do wszystkich aktywnych planistów ścieżek, które iteruje przez każdy krok, równomiernie dzieląc między nimi dostępne zasoby procesora. Gdy wyszukiwanie zakończy się pomyślnie lub ścieżka nie zostanie znaleziona, planista ścieżki powiadomi właściciela, wysyłając mu wiadomość. Rysunek pokazuje schemat sekwencji typu UML procesu.



Czas przyjrzeć się szczegółowym modyfikacjom wymaganym do przeprowadzenia tego procesu. Zacznijmy od zbadania, w jaki sposób adaptowane są klasy algorytmów wyszukiwania A* i Dijkstry.

Modyfikowanie algorytmów wyszukiwania w celu dostosowania do podziału czasu

Algorytmy wyszukiwania A* i Dijkstra zawierają pętlę, która powtarza następujące kroki:

1. Złap następny węzeł z kolejki priorytetowej. 2. Dodaj węzeł do drzewa najkrótszych ścieżek.
3. Sprawdź, czy węzeł jest celem.
4. Jeśli węzeł nie jest celem, sprawdź węzły, z którymi jest połączony, umieszczając je w kolejce priorytetowej, gdy jest to właściwe. Jeśli węzłem jest cel, zwrot sukcesu.

Będziemy odnosić się do pojedynczej iteracji tych kroków jako cyklu wyszukiwania. Ponieważ powtarzane iteracje ostatecznie zakończą wyszukiwanie, można użyć cykli wyszukiwania, aby podzielić wyszukiwanie na wiele etapów czasowych. W konsekwencji klasy algorytmów wyszukiwania A * i Dijkstry są modyfikowane, aby zawierały metodę o nazwie CycleOnce, która zawiera kod wymagany do podjęcia pojedynczego cyklu wyszukiwania. Jest to stosunkowo łatwe, tworząc instancję kolejki priorytetowej jako członka klasy i inicjując ją za pomocą indeksu węzła źródłowego w konstruktorze. Ponadto algorytm musi zostać nieznacznie zmodyfikowany, aby CycleOnce zwrócił wyliczoną wartość wskazującą status wyszukiwania. Może to być jeden z następujących stanów: docelowy_found, docelowy_found lub search_incomplete. Klient może wtedy wywoływać CycleOnce wielokrotnie, dopóki zwracana wartość nie wskaże zakończenia wyszukiwania. Oto lista metody CycleOnce algorytmu A* z ustalonym przedziałem czasowym.

template < class graph_type, class heuristic >
int Graph_SearchAStar_TS::CycleOnce()
{
// jeśli PQ jest pusty, cel nie został znaleziony
if (m_pPQ->empty())
{
return target_not_found;
}
// pobierz węzeł o najniższym koszcie z kolejki
int NextClosestNode = m_pPQ->Pop();
// umieść węzeł w SPT
m_ShortestPathTree[NextClosestNode] = m_SearchFrontier[NextClosestNode];
// jeśli cel został znaleziony, wyjdź
if (NextClosestNode == m_iTarget)
{
return target_found;
}
// teraz, aby przetestować wszystkie krawędzie dołączone do tego węzła
Graph::ConstEdgeIterator EdgeItr(m_Graph, NextClosestNode);
for (const GraphEdge* pE=EdgeItr.beg(); !EdgeItr.end(); pE=EdgeItr.nxt())
{
/* TEN SAM, JAK W POPRZEDNIM ALGORYTMIE */
}
// są jeszcze węzły do zbadania
return search_incomplete;
}

WSKAZÓWKA. Jeśli gra wykorzystuje agentów rozmieszczonych w oddziałach lub plutonach, nie musisz planować ścieżki dla każdego członka plutonu za każdym razem, gdy pluton musi przejść z punktu A do B; wystarczy zaplanować pojedynczą ścieżkę dla lidera plutonu i sprawić, aby wszyscy inni członkowie plutonu podążali za tym liderem (stosując odpowiednie zachowanie sterujące).

Tworzenie wspólnego interfejsu dla algorytmów wyszukiwania

Biorąc pod uwagę, że planista ścieżki może wykorzystywać wyszukiwania A * i Dijkstry (odpowiednio do wyszukiwania pozycji lub pozycji), wygodnie jest im udostępnić wspólny interfejs. W rezultacie zarówno pokrojona w czasie klasa A *, jak i pokrojona w czasie klasa Dijkstry wywodzą się z wirtualnej klasy o nazwie Graph_SearchTimeSliced. Oto deklaracja interfejsu:

template < class edge_type >
class Graph_SearchTimeSliced
{
public:
enum SearchType{AStar, Dijkstra};
private:
SearchType m_SearchType;
public:
Graph_SearchTimeSliced(SearchType type):m_SearchType(type){}
virtual ˜Graph_SearchTimeSliced(){}
// Po wywołaniu ta metoda uruchamia algorytm przez jeden cykl wyszukiwania.
// metoda zwraca wyliczoną wartość (target_found, target_not_found,
// search_incomplete) wskazujący status wyszukiwania
virtual int CycleOnce()=0;
// zwraca wektor krawędzi zbadanych przez algorytm
virtual std::vector GetSPT()const=0;
// zwraca całkowity koszt do celu
virtual double GetCostToTarget()const=0;
// zwraca ścieżkę jako listę PathEdges
virtual std::list GetPathAsPathEdges()const=0;
SearchType GetType()const{return m_SearchType;}
};

Klasa planowania ścieżki może teraz tworzyć instancję dowolnego rodzaju wyszukiwania i przypisywać ją do pojedynczego wskaźnika. Poniższa lista jest zaktualizowaną wersją klasy Raven_PathPlanner i ilustruje dodatkowe dane i metody wymagane do ułatwienia tworzenia żądań ścieżki podzielonej na przedziały czasowe.

class Raven_PathPlanner
{
private:
// wskaźnik do wystąpienia bieżącego algorytmu wyszukiwania wykresów.
Graph_SearchTimeSliced* m_pCurrentSearch;
/* DODATKOWE SZCZEGÓŁY POMINIĘTE */
public:
// tworzy instancję wyszukiwania według przedziału czasu A * i rejestruje ją w
// menedżer ścieżek
bool RequestPathToItem(unsigned int ItemType);
// tworzy instancję czasowego wyszukiwania i rejestrów Dijkstry
// z menedżerem ścieżki
bool RequestPathToTarget(Vector2D TargetPos);
// menedżer ścieżek wywołuje to w celu wykonania iteracji raz przez cykl wyszukiwania
// aktualnie przypisanego algorytmu wyszukiwania. Po zakończeniu wyszukiwania
// metoda wysyła właścicielowi wiadomość z msg_NoPathAvailable lub
// msg_PathReady wiadomości.
int CycleOnce()const;
// wywoływany przez agenta po otrzymaniu powiadomienia o zakończeniu wyszukiwania
//z powodzeniem. Metoda wyodrębnia ścieżkę z m_pCurrentSearch, dodaje // dodatkowe krawędzie odpowiednie do typu wyszukiwania i zwraca je jako listę
// PathEdges.
Path GetPath();
};

Metoda Raven_PathPlanner :: CycleOnce wywołuje metodę CycleOnce aktualnie utworzonego wystąpienia wyszukiwania i sprawdza wynik. Jeśli wynik wskazuje na sukces lub niepowodzenie, do właściciela klasy wysyłany jest komunikat, aby umożliwić podjęcie odpowiednich działań. Aby to wyjaśnić, oto wykaz tej metody:

int Raven_PathPlanner::CycleOnce()const
{
assert (m_pCurrentSearch &&
": No search object instantiated");
int result = m_pCurrentSearch->CycleOnce();
// powiadom bota o niepowodzeniu znalezienia ścieżki
if (result == target_not_found)
{
Dispatcher->DispatchMsg(SEND_MSG_IMMEDIATELY,
SENDER_ID_IRRELEVANT,
m_pOwner->ID(),
Msg_NoPathAvailable,
NO_ADDITIONAL_INFO);
}
// powiadom bota o znalezieniu ścieżki
else if (result == target_found)
{
// jeśli szukano typu elementu, to ostatni węzeł na ścieżce będzie
// reprezentują wyzwalacza. W związku z tym warto przekazać wskaźnik
// do wyzwalacza w dodatkowym polu informacyjnym wiadomości. (Wskaźnik
// będzie po prostu NULL, jeśli nie ma wyzwalacza)
void* pTrigger =
m_NavGraph.GetNode(m_pCurrentSearch->GetPathToTarget().back()).ExtraInfo();
Dispatcher->DispatchMsg(SEND_MSG_IMMEDIATELY,
SENDER_ID_IRRELEVANT,
m_pOwner->ID(),
Msg_PathReady,
pTrigger);
}
return result;
}

Przyjrzyjmy się teraz klasie, która zarządza wszystkimi żądaniami wyszukiwania.

Menedżer ścieżek

Menedżer ścieżek to szablon klasy o nazwie, co nie dziwi, PathManager. Kiedy bot wysyła żądanie trasy za pomocą swojego narzędzia do planowania ścieżki, planista tworzy instancję odpowiedniego typu wyszukiwania (A * dla pozycji, Dijkstra dla typów) i rejestruje się w menedżerze ścieżek. Menedżer ścieżek prowadzi listę wszystkich aktywnych żądań wyszukiwania, które aktualizuje za każdym razem. Oto jego definicja:

template < class path_planner >
class PathManager
{
private:
// kontener wszystkich aktywnych żądań wyszukiwania
std::list m_SearchRequests;
// to całkowita liczba cykli wyszukiwania przydzielonych menedżerowi.
// Każdy krok aktualizacji są równo podzielone między wszystkie zarejestrowane ścieżki
// wnioski
unsigned int m_iNumSearchCyclesPerUpdate;
public:
PathManager(unsigned int NumCyclesPerUpdate);
// za każdym razem, gdy jest to wywoływane, całkowita liczba dostępnych cykli wyszukiwania

// być równo podzielone między wszystkie aktywne żądania ścieżki. Jeśli wyszukiwanie
// zakończy się pomyślnie lub zakończy się niepowodzeniem, metoda powiadomi odpowiedniego bota
void UpdateSearches();
// planista ścieżki powinien wywołać tę metodę, aby zarejestrować wyszukiwanie w
//menedżer. (Metoda sprawdza, czy planer ścieżki jest zarejestrowany tylko
// raz)
void Register(path_planner* pPathPlanner);
// agent może użyć tej metody do usunięcia żądania wyszukiwania.
void UnRegister(path_planner* pPathPlanner);
};

Menedżer ścieżek ma przydzieloną liczbę cykli wyszukiwania, których może użyć do aktualizacji wszystkich aktywnych wyszukiwań na każdym etapie aktualizacji. Po wywołaniu UpdateSearches przydzielone cykle są równo dzielone między zarejestrowanymi planistami ścieżek, a metoda CycleOnce każdego aktywnego wyszukiwania jest nazywana odpowiednią liczbę razy. Gdy wyszukiwanie zakończy się powodzeniem lub niepowodzeniem, menedżer ścieżek usuwa żądanie wyszukiwania z listy. Oto lista metod twojego przeszukiwania.

template < class path_planner >
inline void PathManager::UpdateSearches()
{
int NumCyclesRemaining = m_iNumSearchCyclesPerUpdate;
// iteruj po żądaniach wyszukiwania, dopóki wszystkie żądania nie zostaną wykonane // spełnione lub w tym kroku aktualizacji nie ma żadnych cykli wyszukiwania.
std::list::iterator curPath = m_SearchRequests.begin();
while (NumCyclesRemaining-- && !m_SearchRequests.empty())
{
// wykonaj jeden cykl wyszukiwania dla tego żądania ścieżki
int result = (*curPath)->CycleOnce();
// jeśli wyszukiwanie zostało zakończone, usuń z listy
if ( (result == target_found) || (result == target_not_found) )
{
// usuń tę ścieżkę z listy ścieżek
curPath = m_SearchRequests.erase(curPath);
}
//przejdź do następnego
else
{
++curPath;
}
// iterator może teraz wskazywać na koniec listy. Jeśli tak jest,
// należy go zresetować do początku.
if (curPath == m_SearchRequests.end())
{
curPath = m_SearchRequests.begin();
}
}//end while
}

UWAGA. Zamiast ograniczać menedżera ścieżek do kilku cykli wyszukiwania, możesz preferować przydzielenie określonej ilości czasu na użycie każdej aktualizacji do przeszukiwania ścieżki. Można to łatwo osiągnąć, dodając kod, aby wyjść z metody PathPlanner :: UpdateSearches po przekroczeniu przydzielonego czasu.

Tworzenie i rejestrowanie wyszukiwania

Jak widzieliśmy, każdy bot Raven posiada instancję klasy Raven_Path- Planner. Aby umożliwić tworzenie żądań ścieżki podzielonej czasowo, klasa ta została zmodyfikowana tak, aby była właścicielem wskaźnika do instancji algorytmu wyszukiwania podzielonego czasowo (instancja klasy Graph_SearchDijkstra_TS, gdy bot żąda ścieżki do typu elementu i instancji klasy Graph_SearchAStar_TS, jeśli bot żąda ścieżki do pozycji docelowej). Te instancje są tworzone i rejestrowane w menedżerze wyszukiwania w metodach Request-PathToTarget lub RequestPathToItem. Oto, w jaki sposób wysyłane jest zapytanie o wyszukiwanie przedmiotów:

bool Raven_PathPlanner:: RequestPathToItem(unsigned int ItemType)
{
//wyczyść listę punktów i usuń wszelkie aktywne wyszukiwanie
GetReadyForNewSearch();
// znajdź najbliższy widoczny węzeł od pozycji bota
int ClosestNodeToBot = GetClosestNodeToPosition(m_pOwner->Pos());
// usuń węzeł docelowy z listy i zwróć false, jeśli nie jest widoczny
// znaleziono węzeł. Nastąpi to, jeśli nawigator jest źle zaprojektowany lub jeśli bot
// udało się dostać * do * geometrii (otoczonej ścianami)
// lub przeszkoda
if (ClosestNodeToBot == no_closest_node_found)
{
return false;
}
// utwórz instancję klasy wyszukiwania Dijkstra
typedef FindActiveTrigger< Trigger > term_con;
typedef Graph_SearchDijkstra_TS< Raven_Map::NavGraph, term_con> DijSearch;
m_pCurrentSearch = new DijSearch(m_pWorld->GetNavigationGraph(),
ClosestNodeToBot,
ItemType);
// i zarejestruj wyszukiwanie w menedżerze ścieżek
m_pWorld->GetPathManager()->Register(this);
return true;
}

Po zarejestrowaniu menedżer ścieżki będzie wywoływał metodę CycleOnce odpowiedniego algorytmu na każdym etapie aktualizacji, aż wyszukiwanie zakończy się powodzeniem lub niepowodzeniem. Gdy agent zostanie powiadomiony o znalezieniu ścieżki, pobiera ją z narzędzia do planowania ścieżki, wywołując metodę Raven_PathPlanner :: GetPath.

Zapobieganie kręceniu kciukami

Jedną z konsekwencji planowania ścieżki podzielonej na czas jest opóźnienie od momentu, gdy agent zażąda ścieżki, do momentu otrzymania powiadomienia o pomyślnym lub nieudanym wyszukiwaniu. To opóźnienie jest proporcjonalne do wielkości wykresu nawigacyjnego, liczby cykli wyszukiwania na aktualizację przypisanych do menedżera wyszukiwania oraz liczby aktywnych żądań wyszukiwania. Opóźnienie może być bardzo małe, zaledwie kilka kroków aktualizacji lub może być duże, nawet o kilka sekund. W przypadku niektórych gier może być w porządku, aby agent siedział i kręcił kciukami w tym okresie, ale dla wielu ważne jest, aby agent zareagował natychmiast w jakiś sposób. W końcu, gdy gracz gry kliknie NPC, a następnie kliknie lokalizację, do której ten NPC ma się przenieść, oczekuje, że zareaguje on bezzwłocznie i gra nie będzie pod wrażeniem, jeśli tak się nie stanie. Co zatem robi nasz biedny mały agent gier w tej sytuacji? Musi zacząć się poruszać, zanim zostanie sformułowana ścieżka, ale dokąd? Prostą opcją, której użyłem dla Ravena, jest szukanie przez agenta celu, dopóki nie otrzyma powiadomienia od menedżera wyszukiwania że ścieżka jest gotowa, w tym czasie podąża ścieżką. Jeśli jednak bot poprosi o wyszukiwanie do typu elementu, lokalizacja celu jest nieznana do czasu zakończenia wyszukiwania (ponieważ może istnieć wiele takich przypadków). W tej sytuacji bot po prostu błąka się, dopóki nie otrzyma powiadomienia. Działa to dobrze w większości przypadków, ale stanowi kolejny problem: do czasu sformułowania ścieżki agent mógł przesunąć się dość daleko od pozycji, w której początkowo żądano wyszukiwania. W związku z tym pierwsze kilka węzłów ścieżki zwróconych z planisty zostanie zlokalizowanych w taki sposób, że agent będzie musiał cofnąć się, aby je śledzić. Przykładem tego jest rysunek poniższy. W A bot poprosił o ścieżkę od planisty ścieżki i podczas opóźnienia szuka w kierunku celu. Rysunek B pokazuje pozycję w momencie, gdy bot otrzymuje powiadomienie o sformułowaniu ścieżki. Jak widać, pozostawiony własnym urządzeniom bot odwróci się i cofnie, aby podążać punktami trasy. Zły bot! Niegrzeczny bot!



Na szczęście mamy już rozwiązanie tego problemu. Gdy na ścieżce zastosowany zostanie algorytm wygładzania, wszystkie nadmiarowe punkty trasy są automatycznie usuwane, co daje bardziej naturalnie wyglądającą ścieżkę pokazaną na rysunku. Dlatego planowanie ścieżki o określonym czasie powinno być zawsze stosowane w połączeniu z pewnego rodzaju wygładzaniem ścieżki.



Możesz obserwować te efekty z pierwszej ręki, uruchamiając demo Raven_TimeSlicing i obserwując boty poruszające się po środowisku z włączonym lub wyłączonym wygładzaniem. (Liczba cykli wyszukiwania dostępnych dla menedżera wyszukiwania na aktualizację jest bardzo mała, aby podkreślić efekt.)

WSKAZÓWKA.Jeśli zauważysz, że twoi agenci często śledzą się nawzajem w jednym pliku, aby dotrzeć do lokalizacji lub elementu, i używasz A* do generowania ścieżek, możesz zmieniać ścieżkę wygenerowaną przez algorytm wyszukiwania, dodając trochę szumu do heurystyczny. Spowoduje to nieco inne ścieżki dla tego samego wyszukiwania.

Hierarchiczne wyszukiwanie ścieżek

Inna dostępna technika zmniejszania obciążenia procesora grafem wyszukiwania nosi nazwę hierarchicznego wyszukiwania ścieżek. Działa w podobny sposób, jak ludzie poruszają się po swoim otoczeniu. (Cóż, niezupełnie, ale to stanowi dobry przykład.) Załóżmy na przykład, że budzisz się w środku nocy i decydujesz się na szklankę mleka. Na jednym poziomie świadomości prawdopodobnie będziesz podążał ścieżką przechodzącą przez szereg pokoi (np. Sypialnia do szczytu schodów na dół schodów do korytarza do jadalni do kuchni), ale na innym planujesz ścieżki między pokojami jako dotrzesz do nich. Na przykład, gdy dotrzesz do jadalni, twój mózg automatycznie obliczy ścieżkę do kuchni, która może wymagać obejścia stołu, unikania komody pełnej talerzy, otwierania drzwi i próbowania nie kopnięcia miski z wodą dla psa. Jako taki, twój umysł planuje ścieżki na kilku różnych poziomach - przy różnych szczegółowościach. Innym sposobem spojrzenia na to jest to, że na jednym poziomie ścieżka jest planowana przy użyciu szeregu obszarów (jadalnia, kuchnia itp.), A na niższym poziomie jest planowana przy użyciu szeregu punktów przez te obszary. Tę koncepcję można powtórzyć w komputerze, projektując planer ścieżki, który wykorzystuje dwa nałożone na siebie wykresy o różnych ziarnistościach - jeden gruby, drugi drobny. Na przykład wyobraź sobie grę strategiczną opartą na wojnie secesyjnej w Ameryce. Hierarchiczny planista ścieżki dla tej gry może wykorzystywać gruboziarnisty wykres do reprezentowania informacji o łączności na poziomie stanu oraz drobno granulowany na poziomie miast i dróg. Gdy jednostka wojskowa prosi o trasę z Atlanty do Richmond, planista ścieżki określa, w których stanach leżą te miasta - Georgia i Wirginia - i oblicza drogę między nimi za pomocą stanowego nawigatora: Georgia do Południowej Karoliny, Karolina Północna i Wirginia. Ścieżkę tę można obliczyć niezwykle szybko, ponieważ wykres zawiera tylko kilkadziesiąt węzłów, po jednym dla każdego stanu reprezentowanego w grze. Następnie planista używa drobnoziarnistego nawigatora do obliczania ścieżek między stanami, gdy i kiedy jednostka ich potrzebuje. W ten sposób wyszukiwanie drobnoziarnistego wykresu jest utrzymywane na niskim poziomie, a zatem szybkie.

UWAGA. Chociaż użycie dwóch warstw graficznych jest najbardziej typową implementacją hierarchicznego wyszukiwania ścieżek, nic nie stoi na przeszkodzie, abyś używał większej liczby warstw, jeśli środowisko gry jest wystarczająco złożone, aby to uzasadnić.
Stosując ten sam pomysł do mapy Raven_DM1, planista ścieżki mógłby użyć wykresów pokazanych na rysunku . Wykres po lewej stronie może służyć do szybkiego określania ścieżek na poziomie "pokoju", a wykres po prawej stronie do określania ścieżek między pokojami na poziomie "punktu".



Oczywiście jest to trywialny przykład; hierarchiczne wyszukiwanie ścieżek powinno się rozważać tylko wtedy, gdy gra wymaga planowania ścieżek w dużych i / lub złożonych środowiskach.

Wyjście z lepkich sytuacji

Problem, który gracze w grach komputerowych obserwują zbyt często, to utknięcie NPC. Może się to zdarzyć z różnych powodów. W szczególności występuje często, gdy środowisko zawiera wiele czynników, a geometria ma wąskie gardła. Wąskim gardłem może być mała przestrzeń między dwiema przeszkodami, wąskie drzwi lub wąski korytarz. Jeśli zbyt wielu agentów jednocześnie próbuje pokonać wąskie gardło, to niektórzy z nich mogą zostać zepchnięci do tyłu i zaklinować się na ścianie lub przeszkodzie. Przyjrzyjmy się temu z prostym przykładem. Rysunek pokazuje bota - nazwiemy go Eric - podążając ścieżką od A do B do C. Pokazuje także liczbę innych botów podróżujących w przeciwnym kierunku. Eric czeka na paskudną niespodziankę.



Na rysunku Eric osiągnął punkt A, więc został usunięty z listy i B przypisany jako następny punkt. Niestety, gdy tak się dzieje, przybywają inne boty i zaczynają przepychać Erica przez drzwi.



Na tym rysunku8 Eric został wypchnięty z powrotem przez drzwi, ale wciąż szuka drogi do punktu B. Głupi stary Eric.



W końcu Eric zostaje przyciśnięty do ściany, wciąż walcząc wściekle i beznadziejnie próbuje dotrzeć do następnego punktu, jak pokazano na rysunku

TSK TSK.

Oczywiście nie chcemy, aby coś takiego się wydarzyło, więc każda sztuczna inteligencja warta swojej soli powinna regularnie testować takie sytuacje i odpowiednio planować. Ale jak to zrobić? Jednym ze sposobów jest obliczenie przez agenta odległości do bieżącego punktu na każdym kroku aktualizacji. Jeśli ta wartość pozostaje mniej więcej taka sama lub stale rośnie, oznacza to uczciwy zakład, że agent utknął lub został popchnięty do tyłu przez siłę oddzielającą od sąsiednich agentów. Innym sposobem jest obliczenie oczekiwanego czasu przybycia dla każdego punktu trasy i ponowne zaplanowanie, jeśli aktualny czas przekroczy oczekiwany czas. Takie jest podejście przyjęte w Raven. Jest bardzo prosty do wdrożenia. Za każdym razem, gdy nowa krawędź jest wyciągana ze ścieżki, oczekiwany czas na przejście jest obliczany w następujący sposób (w pseudokodzie):

Edge next = GetNextEdgeFromPath (ścieżka)
// w prostym grafiku kosztem krawędzi jest długość krawędzi
ExpectedTimeToReachPos = next.cost / Bot.MaxSpeed
// uwzględnić margines błędu
MarginOfError = 2.0;
ExpectedTimeToReachPos + = MarginOfError;

Margines błędu służy do uwzględnienia wszelkich reaktywnych zachowań bota podczas podróży, takich jak skręcanie w bok w celu uniknięcia kolejnego bota lub przepychanie się w drzwiach i wąskich przejściach. Margines ten powinien być wystarczająco mały, aby uniemożliwić agentom wyglądanie na głupie, a jednocześnie wystarczająco duży, aby uniemożliwić agentom częste żądanie nowych ścieżek od narzędzia do planowania ścieżek.

UWAGA Możesz zaobserwować, że boty blokują się, jeśli uruchomisz demo Raven_BotsGetting- Stuck. W wersji demo kilka botów bada mapę. Z ich aktualnej pozycji rysowana jest strzałka do ich bieżącego miejsca docelowego. Kiedy przepychają się przez drzwi, niektóre z nich utkną, a niektóre na stałe.

Podsumowanie

Przedstawiono wiele metod i technik związanych z planowaniem ścieżki. Większość pomysłów została włączona do frameworka gry Raven, dzięki czemu możesz zobaczyć, jak działają na miejscu i zbadać kod, aby zobaczyć, jak wszystko działa razem. Pamiętaj, że jest to tylko przykład. Zwykle nie używałbyś wszystkich tych technik jednocześnie. Po prostu używaj tego, czego wymaga Twoja gra, i nic więcej.

Praktyka czyni mistrza

Po przejściu do pozycji docelowej boty Raven wypełniają lukę utworzoną przez czas potrzebny na przeszukiwanie wykresu, szukając tej pozycji. Jest to tanie i łatwe do wdrożenia, ale w grach z setkami agentów lub dużymi nawigatorami opóźnienie może być zbyt długie, aby takie podejście było skuteczne. Biorąc pod uwagę zbyt długie opóźnienie, agenci zaczną głupio chodzić po ścianach i innych przeszkodach. Są też chwile, w których najlepsza ścieżka do pozycji polega na oddaleniu się od celu lub odchyleniu go od niego przed pochyleniem się, aby stawić mu czoła.



Szukanie w takich sytuacjach przez dłuższy czas jest zdecydowanie nie-nie. Zamiast tego agenci muszą określić częściową ścieżkę do pozycji docelowej. Oznacza to, że algorytm A* musi zostać zmodyfikowany, aby zwracał ścieżkę do węzła najbliższego celowi po osiągnięciu zdefiniowanej przez użytkownika liczby cykli wyszukiwania lub głębokości wyszukiwania. Agent może następnie podążać tą ścieżką, dopóki nie zostanie utworzona pełna ścieżka. Spowoduje to znacznie lepsze zachowanie i zmniejszy szanse, że agent będzie wyglądał głupio. Twoim zadaniem, jeśli zdecydujesz się to zaakceptować, jest zmodyfikowanie projektu PathPlanner w celu utworzenia częściowych ścieżek między węzłami źródłowym i docelowym.