Unity3d QuickTip – czyli szybkie porady, rozwiązania częstych problemów i sztuczki w Unity3d!

Dzisiejszy odcinek: Multiplayer w Unity3d z wykorzystaniem UNet. Część 2

Uwaga! Jest to poradnik typu QuickTip. Zatem skupia się on na osiągnięciu założonego celu. Zatem zakładamy że użytkownik zna na tyle program Unity3d, aby samodzielnie wykonać najprostsze czynności, jak np. dodanie modelu kostki do sceny czy dodanie modelowi jakiegoś komponentu. Jeżeli brakuje Ci tej podstawowej wiedzy, zapraszam do tutoriala:
Unity Tutorial – Podstawy

Kurs powstał na podstawie artykułu Christiana Arellano.

Ten poradnik jest rozwinięciem poprzedniego QuickTipa dotyczącego UNet. Jeśli go nie przerabiałeś, wróć teraz do niego i wykonaj go w całości. Będziemy pracować na projekcie, który powstał w wyniku tego poprzedniego tutoriala i bez niego, nie jesteś w stanie wykonać tej części.

Teoria

W poprzedniej części udało nam się wykonać bardzo prostą grę multiplayer, gdzie jeden użytkownik hostuje grę, a drugi może się do niej podłączyć. Gra działała (dało się poruszać postaciami), a ruch był od razu przekazany do drugiego komputera. Problemem był jednak brak płynności rozgrywki i widoczne lagi. Dzisiaj postaramy się im zaradzić.

Problem naszej gry wynika trochę ze specyfiki sieci i budowy naszej gry. Tzn. gdy gracz naciśnie klawisz, wysyłamy to wydarzenie do hosta. Host wylicza co powinno się zdarzyć (jak powinien przesunąć się gracz) i wysyła nowe współrzędne. Klient wprowadza nowe współrzędne. Całe to odbieranie i wysyłanie zajmuje pewien skończony czas. I nawet jeśli są to milisekundy, to w dalszym ciągu zauważymy to jako lag, albo inaczej: opóźnienie (delay).

Takie problem można rozwiązać mechanizmem przewidywania. Tzn. gdy gracz naciśnie klawisz, to tak jak wcześniej wysyłamy dane do hosta, ale jednocześnie przewidujemy co powinno się stać. W naszym przypadku chodzi o ruch, więc od razu przesuwamy gracza w danym kierunku. Gdy dostaniemy odpowiedź z serwera, to wystarczy sprawdzić, czy dobrze przewidzieliśmy ruch. Jeśli tak, to super, jeśli nie, to należy pozycję skorygować.

Jednak co jeśli wyślemy dwa komunikaty? Przemieszczamy się np. dwa razy do przodu. Przewidujemy oba ruchy i wtedy dostajemy zwrot od hosta i okazuje się, że jesteśmy teoretycznie w złym miejscu i następuje korekta. Potem przychodzi drugi komunikat i znów korygujemy, ustawiając gracza tam gdzie skończył za pierwszym razem. Co zobaczy gracz? Przeskoki swojej postaci, co będzie jeszcze gorsze niż opóźnienie. Żeby temu zapobiec wprowadza się kolejkę komunikatów. Dzięki czemu możemy weryfikować czy stan jest poprawny bez mieszania obiektem.

Przejdźmy do dzieła.

Przygotowanie kolejki

Przygotowanie kolejki jest raczej proste. Po pierwsze tworzymy sobie jej obiekt:

[SyncVar] CubeState state;
Queue<KeyCode> pendingMoves;

Korzystamy tutaj z typu Queue, czyli… kolejka. Jest to bardzo wygodna forma dla naszego zadania, ale o tym później. Przygotowaną kolejkę należy zainicjować. Zrobimy to w funkcji Start:

void Start()
{
    if (isLocalPlayer) {
        pendingMoves = new Queue<KeyCode>();
    }
}

Znów posługujemy się isLocalPlayer, ponieważ dana kolejka ma być jedynie kolejką lokalną. Inni gracze, nie muszą znać naszych przewidywań. Warto tutaj też odnotować, że Queue znajduje się w bibliotece System.Collections.Generic. Więc jeśli jeszcze tego nie zrobiliśmy, na początku skryptu należy dodać:

using System.Collections.Generic;

Teraz wypadłoby coś umieścić w tej kolejce. W tym celu modyfikujemy naszą funkcję Update:

void Update()
{
    if (isLocalPlayer) {
        KeyCode[] arrowKeys = { KeyCode.UpArrow, KeyCode.DownArrow, KeyCode.RightArrow, KeyCode.LeftArrow };
        foreach (KeyCode arrowKey in arrowKeys) {
            if (!Input.GetKey(arrowKey)) continue;
            pendingMoves.Enqueue(arrowKey); 
            CmdMoveOnServer(arrowKey);
        }
    }
    SyncState();
}

Tutaj tylko dodajemy do kolejki ostatni kliknięty klawisz. Na razie tyle.

Przewidywanie

Teraz czas przygotować system przewidywania. Ogólnie przewidywanie to dwie rzeczy. Ostatnia odpowiedź serwera, oraz wszystkie nasze ruchy, nowsze niż odpowiedź serwera. Czyli, aktualna pozycja to: ostatnia odpowiedź z serwera plus wszystkie kolejne kliknięcia gracza, które nastąpiły po tej odpowiedzi. Powinniśmy też dokonać zmiany, za każdym razem, gdy nastąpi jakaś zmiana, tzn. gdy dostaniemy nowszą odpowiedź, albo gracz wykona kolejny ruch.

To czego nam jednak brakuje to wiedza, który komunikat jest nowszy. Mając odpowiedź serwera i kolejkę komunikatów, nie możemy określić co było nowsze. Dlatego, teraz zmodyfikujemy nieco budowę struktury:

struct CubeState
{
    public int moveNum;
    public float x;
    public float y;
    public float z;
}

Dodatkowa zmienna będzie przechowywać “wiek” stanu.

Lekko też zamieszamy zmiennymi. Nasze state stało się teraz serverState, a dodatkowo tworzymy sobie serverState. W tym miejscu należy zmienić każde wystąpienie zmiennej State, na serverState. Nazwa nie ma znaczenia, ale robimy to, żeby mieć świadomość, który stan jest którym. Dodatkowo pojawia się drugi stan: predictedState.

Queue pendingMoves; 
CubeState predictedState;
[SyncVar] CubeState serverState;

Teraz należałoby jakoś wypełnić numerki naszych stanów:

CubeState Move(CubeState previous, KeyCode arrowKey)
{
    float dx = 0;
    float dy = 0;
    float dz = 0;

    switch (arrowKey)
    {
        case KeyCode.UpArrow:
            dz = Time.deltaTime;
            break;
        case KeyCode.DownArrow:
            dz = -Time.deltaTime;
            break;
        case KeyCode.RightArrow:
            dx = Time.deltaTime;
            break;
        case KeyCode.LeftArrow:
            dx = -Time.deltaTime;
            break;
    }

    return new CubeState
    {
        moveNum = 1 + previous.moveNum,
        x = dx + previous.x,
        y = dy + previous.y,
        z = dz + previous.z
    };
}

Po prostu zwiększamy sobie numery o 1 względem poprzedniego. W efekcie będą to numery 1, 2, 3 etc. Modyfikujemy też inicjację, aby nasz Cube zawsze miał numer, a przy okazji zmieniamy nazwę na serverState, jeśli nie zrobiliśmy tego przy zmianie wszystkich zmiennych state.

[Server]
void InitState()
{
    serverState = new CubeState
    {
	moveNum = 0,
        x = 0,
        y = 0,
        z = 0
    };
}

Można teraz zauważyć, że nigdzie nie podajemy początkowej wartości zmiennej predictedState. Dlatego dodajemy sobie ten kod do funkcji Start:

void Start()
{
    if (isLocalPlayer) {
		pendingMoves = new Queue<KeyCode>();

		predictedState = new CubeState
		{
			moveNum = 0,
			x = 0,
			y = 0,
			z = 0
		};
    }
}

Teraz należałoby się upewnić, że w naszej kolejce są same ruchy, które są nowsze od naszej ostatniej odpowiedzi serwera. Dlatego zrobimy sobie taką funkcję:

void OnServerStateChanged(CubeState newState)
{
    serverState = newState;
    if (pendingMoves != null) {
        while (pendingMoves.Count > (predictedState.moveNum - serverState.moveNum)) {
            pendingMoves.Dequeue();
        }
        UpdatePredictedState();
    }
}

Co tu się dzieje? Podany jako parametr stan, jest odpowiedzią serwera. Badamy sobie kolejkę. Jeśli w kolejce jest więcej elementów, niż wynosi różnica między ostatnim przewidywanym ruchem, a odpowiedzią serwera, to znaczy że mamy jakieś stare wartości i wystarczy je usunąć. Następnie idzie funkcja UpdatePredictedState, o której za chwilę.

Postaram się wyjaśnić jak działa to usuwanie. Do przechowywania rozkazów, wykorzystaliśmy kolejkę i jej dwie funkcję: Enqueue i Dequeue. Ich działanie jest proste: Jedna wstawia do kolejki, druga usuwa z kolejki. Stosujemy tutaj typ: FIFO czyli First In, First Out. Oznacza to, że gdy Enqueue zawsze dodaje na koniec kolejki, to Dequeue usuwa element, który jest w kolejce najdłużej. Najprościej można to wyjaśnić jako kolejkę w sklepie. Nowe osoby ustawiają się na końcu, a pierwsze odchodzą te, które były tam wcześniej. (O FIFO wspominam tylko po to, żeby rozszerzyć ogólną wiedzę informatyczną).

Ale dlaczego takie równanie rozwiązuje nasz problem? Wykonując kolejne ruchy uzupełniamy kolejkę kolejnymi liczbami:

0 – 1 – 2 – 3 – 4 – 5 -6

Naszym predictedState jest teraz numer 6, czyli ostatni w kolejce. Nasz serverState to nasza ostatnia odpowiedź z serwera i będzie to jedna z liczb z przedziału 0 – 6. Numer odpowiedzi serwera jest dla nas kluczowy. Upewnia nas o tym, że jakiś komunikat został wypełniony poprawnie. Więc interesują nas teraz wszystkie komunikaty o wieku odpowiedzi, bądź nowsze od niej, a reszta jest już zbędna.

Powiedzmy, że odpowiedź serwera ma numer 2: 6-2 = 4. Czyli rozmiar kolejki powinien wynosić 4, a obecnie mamy 7. Dlatego zaczynamy usuwać elementy. Tak jak mówiłem, mechanizm kolejki działa tak, że usuwane będą numery, które pojawiły się w niej pierwsze, czyli te najniższe. Więc po każdym obrocie while, nasza kolejka będzie zmieniała się tak:

1 – 2 – 3 – 4 – 5 – 6

2 – 3 – 4 – 5 – 6

3 – 4 – 5 – 6

Efekt? W naszej kolejce przewidzianych kroków, zostały już tylko i wyłącznie te, które są przewidywaniami, a usunęliśmy wszystkie już potwierdzone przez serwer. Przykład z 2 podałem na wyrost, najczęściej usunięty zostanie jeden element, ale większa liczba lepiej obrazuje sytuację. Czas na funkcję UpdatePredictedState:

void UpdatePredictedState()
{
    predictedState = serverState;
    foreach (KeyCode arrowKey in pendingMoves) {
        predictedState = Move(predictedState, arrowKey);
    }
}

Tutaj nie ma nic nadzwyczajnego. Nasz obecny stan, to stan z serwera, plus wszystkie przewidziane ruchy. Jednak została jeszcze jedna rzecz. Chcieli byśmy, żeby funkcja: OnServerStateChanged faktycznie wykonywała się po zmianie odpowiedzi z serwera. Mamy już wykorzystany znacznik [SyncVar], który aktualizuje naszą zmienną po otrzymaniu odpowiedzi z serwera, ale nie robi tego tak, jakbyśmy tego chcieli. Dlatego musimy nieco zmodyfikować kod:

[SyncVar(hook="OnServerStateChanged")] CubeState serverState;

Dzięki temu dopiskowi, gdy zmienna serverState ma zostać zaktualizowana, zostanie wywołana funkcja OnServerStatusChange.

Czas na przedostatnią zmianę:

void SyncState()
{
    CubeState stateToRender = isLocalPlayer ? predictedState : serverState;
    transform.position = new Vector3(stateToRender.x, stateToRender.y, stateToRender.z);
}

Właściwie dodaliśmy pierwszą linię, gdzie badamy czy jesteśmy na serwerze czy na kliencie i w zależności od tego, dokonujemy odpowiedniego przesunięcia. Tzn. gdy jesteśmy lokalnym graczem, wykonujemy przesunięcia wraz z przewidywaniami, jeśli jesteśmy na serwerze, dokonujemy tylko przesunięć zgodnych ze statusem serwera.

Jednak po uruchomieniu gry, zobaczymy śmieszną rzecz. Mianowicie, ruch postaci na serwerze, będzie widoczny na kliencie, a ruch postaci klienta, będzie jedynie widoczny na serwerze. Wynika to z prostego błędu. Aktualizujemy jedynie zmienną serwerową, pomijając lokalną pozycję. Aby to rozwiązać wystarczy dodać jedną linijkę do funkcji Update:

void Update()
{
    if (isLocalPlayer) {
        KeyCode[] arrowKeys = { KeyCode.UpArrow, KeyCode.DownArrow, KeyCode.RightArrow, KeyCode.LeftArrow };
        foreach (KeyCode arrowKey in arrowKeys) {
            if (!Input.GetKey(arrowKey)) continue;
            pendingMoves.Enqueue(arrowKey);
            UpdatePredictedState(); 
            CmdMoveOnServer(arrowKey);
        }
    }
    SyncState();
}

Dzięki temu wymusimy ustalenie nowej pozycji z uwzględnieniem przewidywań na lokalnej maszynie.

Test między komputerami

Gdzieś w między czasie padło pytanie, jak przetestować grę na dwóch maszynach, dlatego na nie odpowiem. No bo po co nam gra, w którą mamy grać na jednym komputerze. Zaczniemy od połączenia lokalnego, czyli dwóch komputerów w jednej sieci. Po uruchomieniu gry, widzimy coś takiego:

Menu połączenia sieciowego
Menu połączenia sieciowego

Host dalej jedynie klika “LAN Host”. Drugi komputer ponownie kliknie w “LAN Client”, jednak przed tym należy zmodyfikować adres, który obecnie jest ustawiony na “localhost”. “Localhost”, to odwołanie do samego siebie, czyli tzw. pętla lookback. Fizycznie? Adres 127.0.0.1. Co pięknie działa, jeśli host i klient są na jednej maszynie. Jeśli host postawiony jest na innej maszynie, należy zamiast “localhost” wprowadzić sobie jego adres IP. Skąd wziąć adres IP?

Należy uruchomić wiersz poleceń (cmd) i wprowadzić polecenie ipconfig. Dostaniemy coś takiego:

Pobieranie adresu IP: ipconfig
Pobieranie adresu IP: ipconfig

Szukany adres IP to, w przykładzie powyżej: 192.168.1.104

A co jeśli chcemy łączyć się po za sieć lokalną? O tym w kolejnej części.

Koniec. Dzięki temu, powinniśmy otrzymać grę, gdzie ruch naszej własnej postaci będzie dużo płynniejszy, bo nie czekamy na odpowiedź serwera. Inne postacie mogą dalej lagować, bo zależne to jest o szybkości łącza, szybkości obliczeń serwera etc.

Wiem że nie jest to pełna gra, czy gotowy skrypt, który można wkleić w grę, ale moim założeniem było dać wędkę, a nie rybę. Wykorzystując poznane techniki i metody, możecie teraz tworzyć własne gry multiplayer.