Jak działają multiplayerowe gry strategiczne?
12.11.2011 | aktual.: 08.12.2011 09:08
Pamiętam jak dziś, kiedy po raz pierwszy zagrałem on‑line w produkcję firmy Blizzard, strategię czasu rzeczywistego, grę Starcraft. Wtedy jako początkującego programisty, urzekł mnie fakt że to wszystko po prostu działa! Na modemie o oszałamiającej prędkości 3 KB/s dziesiątki jednostek poruszały się i walczył bez najmniejszego zająknięcia.
Po kilku latach przeczytałem artykuł podobny do tego wpisu, i zachwycił mnie geniusz ludzi którzy wymyślili w jaki sposób rozwiązać realizację protokół sieciowego w grach strategicznych. Może dzisiaj nie wydaje się to czymś bardzo odkrywczym, nie mniej, chciałbym podzielić się to wiedzą. Zakładam że bardzo dużo dzisiejszych gier działa w podobny sposób. Nawet jeśli nie jesteś programistą a jedynie graczem, zapraszam do zapoznania się z dalszą treścią i zrozumienia zasad na jakich bazują gry strategiczne.
Powróćmy do pierwotnego zadania, wprawić w ruch dziesiątki a często i setki, tysiące jednostek w taki sposób aby każdy z graczy widział na ekranie ten sam efekt, nie przesyłając przy tym ilości danych równych objętości samej gry. Oczywiście to co może wydawać się najłatwiejsze to wysyłanie stanu mapy/jednostek w określonych interwałach czasowych, jednakże danych takich było by bardzo dużo i niekoniecznie mogłyby dojść na czas, aby zapewnić płynną rozgrywkę.
Rozgrywka jako funkcja matematyczna
Patrząc nieco abstrakcyjnie, musimy potraktować cały mechanizm rozgrywki jako jedną dużą funkcję, prawdopodobnie wielu zmiennych. Tak jak funkcja sinus dla danego argumentu w każdym zakątku świata i czasie daje ten sam wynik, tak samo nasza gra, dla określonych danych wejściowych musi dawać zawsze ten sam wyjściowy stan gry/mapy/jednostek.
W takiej sytuacji, przesyłając jedynie argumenty tej funkcji, jesteśmy w stanie odtworzyć u wszystkich klientów sieciowych ten sam stan. Brzmi to za pewno jak przesyłanie całego stanu mapy, jednak tak nie jest.
Zagrajmy zatem
Przejdźmy do żywego przykładu który najlepiej zobrazuje realizację protokołu. Zakładamy że początkowy stan gry jest ogólnie znany wszystkim klientom gry, czyli posiadają oni informację o całej mapie i wszystkich graczach, nawet tych który gracz nie widzi (jest to pewna wada tego rozwiązania ale o tym później). Gra zaczyna toczyć się własnym życiem, jeśli żaden z graczy nie podejmował by interakcji, stan gry zmienia się w ustalony sposób, przykładowo nic się nie dzieje ;). Ok, to jednak była by dość nudna gra. Zatem nasz bohater postanawia wydać rozkaz jednostce X aby przemieściła się do innej lokacji. Co robimy my jako programiści? Wysyłamy tylko informacje o zdarzeniu jakie zaszło, i nie jest to nowa pozycja jednostki! Informujemy pozostałych klientów gry o samym rozkazie, czyli przykładowo że jednostka X została wysłana do lokalizacji o takich i takich współrzędnych, jednak w tej chwili czasu jeszcze się tam nie znajduje.
Co robią pozostali klienci gry? Odtwarzają sytuację na planszy jak film na bazie otrzymanej informacji. I tutaj dość istotna kwestia się pojawia. Algorytm prowadzący grę, zmieniający jej stan, musi być pozbawiony jakichkolwiek elementów czysto losowych. Nie może dojść do sytuacji w której na te same dane wejściowe gra u jednego z graczy zareaguje inaczej niż u innego - musi to być jak wspomniana funkcja matematyczna, jednemu argumentowi przypisana jest jedna i zawsze ta sama wartość. Wraz z upływem czasu, ilość napływających informacji jest coraz większa, a każda z nich mówi nam tylko co mamy zmodyfikować w stanie gry. Po kilkuset tysiącach takich paczek, wszyscy gracze muszą wciąż widzieć ten sam obraz rozgrywki.
Company of Heroes – setki jednostek, wybuchów i akcji w rozgrywce sieciowej
Synchronizacja
Wiemy już co przesyłać, pytanie tylko jak, aby wszystko to działo się w tej samej chwili czasu. Innymi słowy, nie tylko musimy reagować tak samo u wszystkich klientów na dane wejściowe, ale dane te musimy przetwarzać dokładnie w tej samej klatce czasowej rozgrywki aby wywołały ten sam efekt (niekonieczne w tej samej chwili czasu bezwzględnego). Wydany rozkaz w klatce A spowoduje inną reakcję gry niż ten sam rozkaz w klatce A+1, co jest rzeczą naturalną. Dodając do tego opóźnienie związane z przejściem pakietu po sieci internet, mamy niemal gwarancję, że nie uda się już przeliczyć danych gry w tej samej klatce co u klienta który je wysłał.
I tutaj dochodzimy do kwintesencji zagadnienia, gra musi reagować z opóźnieniem na rozkazy wydane przez gracza, nawet na maszynie na której on sam gra. Ten sztuczny czas opóźnienia potrzebny nam jest na wysłanie tego co gracz zrobił zanim u innych gra dotrze do tej samej klatki.
Mówiąc bardziej obrazowo, rozpoczynamy akcję ataku jednostką gry w klatce czasowej A, program jednak nie wykonuje jeszcze tego polecenia a jedynie zapisuje je sobie, wraz z czasem wykonania równym przykładowo A + 100. Wysyłamy je do innych graczy u których dane te trafiają do tej samej kolejki. Gdy licznik klatek osiągnie wartość A + 100, u wszystkich grających zostanie wykonana ta sama procedura postępowania.
Oczywiście to zaplanowane opóźnienie wprowadza pewien dyskomfort gry, im większe ono jest. Aby zapewnić bezproblemowy przebieg potyczki, czas ten musi być większy niż największe opóźnienie między graczami (ping). Nie mniej, w grach strategicznych nawet czas zagapienia się jednostek o 1 sekundę nie powinien być specjalnie uciążliwy (aczkolwiek zależy to od dynamiki gry). Wspomniana na początku gra Starcraft, posiadała ów czas regulowany dynamicznie przez graczy, sławne Extra high latency. Najzabawniejsze było gdy nieobeznane osoby zmieniały go właśnie na tę maksymalną wartość, nie wiedząc do czego to służy, podczas gdy na najniższej wartości gra radziła sobie doskonale a przy tym jednostki szybciej reagowały.
Idąc dalej, nie wysyłamy każdego rozkazu od razu po jego wystąpieniu. Aby to wszystko miało ręce i nogi musimy grupować rozkazy. Podsumujmy wszystko i rozpiszmy w etapy postępowania:
Etap. Klatka czasowa - Czynność
1. A - Zbieramy rozkazy wydane przez gracza ustawiając ich czas wykonania na klatkę A + 2t. 2. A + t - Wysyłamy zbiór zgromadzonych rozkazów do pozostałych klientów gry. 3. A + 2t - Wykonujemy zaplanowane wcześniej rozkazy własne oraz otrzymane.
Każdy z etapów trwa czas równy t, opóźnienie reakcji jednostki w stosunku do wydania jej rozkazu wynosi 2t. Istotną rzeczą o jakiej należy pamiętać to zazębianie się w/w etapów. W każdym przedziale czasu t, jednocześnie wykonujemy wszystkie 3 etapy jednocześnie. Czyli, zbieramy aktualne rozkazy (1), wysyłamy te zebrane etap wcześniej (2), oraz wykonujemy to co otrzymaliśmy dwa etapy temu (3). Jest to konieczne aby zapewnić ciągłość rozgrywki.
Opóźnienia
Nie wspomniałem jeszcze o jednym ważnym szczególe. Wraz z wysyłanymi rozkazami musimy wysłać informację o tym do której klatki czasowej dany klient gry może dojść. W etapie drugim dodajemy dane mówiące o tym że timer gry może dokonać obliczeń do momentu A + 3t, gdyż to co aktualnie wysłaliśmy zawiera dane do tej chwili czasowej. Jeśli gra przebiega bez nieprzewidywalnych opóźnień, to zanim dotrzemy do tej klatki, powinniśmy otrzymać kolejne dane które wydłużą nam ten czas i tak non‑stop.
Co jednak gdy nie otrzymamy opisanych pakietów na czas? Zatrzymujemy grę do czasu aż dotrze do nas brakująca paczka rozkazów. Jeśli jest to spowodowane nagłym pogorszeniem się połączenia z jednym tylko graczem, zamrożenie powinno nastąpić u całej reszty (jak i u samego winowajcy z racji tego że nie otrzyma on danych od reszty). Jeśli czas wstrzymania przekracza jakąś ustaloną wartość (np: 3 sekundy), wyświetlamy okienko informujące że ten a ten rozgrywający nie odpowiada.
Okno oczekiwania na gracza w grze Starcraft
Ograniczenia
Ten sposób realizacji mimo iż jest chyba najlepszym z możliwych ma pewne ograniczenia i wady. Po pierwsze, nie możemy stosować danych losowych gdyż musiały by one we wszystkich instancjach gry być identyczne. Niedogodność tą możemy ominąć inicjując generator liczb losowych tym samym ziarnem u wszystkich klientów, bądź samemu generować liczbę pseudo losową, na przykład za pomocą funkcji mieszających dane gry (które zmieniają się dość dynamicznie, a są u wszystkich takie same).
Drugim poważnym problemem jest desynchronizacja gry. Jako że obecny stan gry jest stanem gry w chwili poprzedniej zmienionym o aktualne dane, zatem im dalej tym większa szansa na to że coś się „rozjedzie”. Dobrym zwyczajem jest wprowadzenie sprawdzania jakiejś sumy kontrolnej stanu gry aby wykryć taki moment. Oczywiście jeśli protokół jest dobrze napisany a transmisja danych pewna, nie ma prawa dojść do takiej sytuacji. Niestety z doświadczenia wiem, że zdarzają się takie przypadki w wielu grach (np: Settlers 3, Company of Heroes). Po wystąpieniu błędu synchronizacji dalsza gra jest już niemożliwa. Jeśli porcja danych opisująca całościowy stan gry nie jest zbyt duża, możemy pokusić się o jej przesłanie i ponowną synchronizację. Jednak patrząc na to ile pamięci zajmują obecne gry (np.: 1GB), zakładając nawet że tylko 10% z tego stanowią dane opisujące świat gry, i tak jest to bardzo dużo.
Błąd synchronizacji w grze Company of Heroes
Jeśli gra oferuje system powtórek, to z pewnością nie umożliwia ich przewijania. Jest to konsekwencją tego że zapisywane są aktualne rozkazy, które służą do odtworzenia aktualnego stanu gry. Trzeba zatem przetworzyć wszystko po kolei aby zobaczyć ost. 10 sekund potyczki. Oczywiście możemy przyspieszyć obliczenia w zależności od skomplikowania gry. Jednakże jeśli produkcja jest tak wymagająca, że rozgrywka w czasie 1:1 obciąża znacznie najnowsze komputery, to nie ma fizycznej możliwości na przeskoczenie dalej.
Na sam koniec zostaje problem bezpieczeństwa. Gra musi dysponować pełnymi informacjami o całej sytuacji rozgrywki, czyli nawet o tych graczach których grający nie widzi na mapie. Niesie to ze sobą pewne niebezpieczeństwa w postaci modyfikacji gry i odczytania tych informacji. Patrząc z drugiej strony, niemożliwe jest wpływanie na te dane, gdyż wystąpiły by błędy synchronizacji.
Podsumowanie
Mam nadzieję że przybliżyłem ten temat w sposób dostatecznie jasny. Zagadnienie to wymaga własnego przemyślenia, aby dobrze zrozumieć co kiedy zebrać i wysłać by trybiki gry się sprawnie kręciły.