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:

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:

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ć:

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

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:

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.

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

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.

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

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ę:

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:

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:

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

Czas na przedostatnią zmianę:

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:

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.

  • Dzięki za poradnik

  • Mam taki błąd

  • Laysiks

    Gdzie dodać ten network manager ? Do kostki czy pusty object + nie respi mi kostki ;x

  • Czy można zrobić to gdzieś na serwerze vps i każdy by mogł wbijać???

    • W kolejna część odpowiada na to pytanie. Nie da się. Jesteśmy uzależnieni od Unity Cloud.

  • Maksymilian Świderski

    mógłbym prosić o cały skrypt moving bo coś mi nie wychodzi?

    • Raczej staram się nie dawać gotowych skryptów, bo takie kopiowanie kodu niczego nie uczy.
      Możesz napisać jaki masz problem i pokazać swój kod i jakoś wymyślimy co nie bangla.
      Skrypt jest też rozbudowaniem skryptu z pierwszej części, więc tam mogło coś pójść nie tak.

    • Krzysztof Prus

      Nie zgadzam się z twoim zdaniem. Jeśli ktoś będzie się chciał tego nauczyć to i tak się nauczy bo nikt nie uczy się Unity z przymusu. Dodatkowo na przykład mi lepiej jest przejrzeć jednolity kod u siebie w edytorze zamiast przewijać komentarze.

    • Niestety moje zdanie oparte jest o doswiadczenie. A duzo osob nie uczy sie, tylko chce zrobic cokolwiek. Widzac gotowy kod kopiuja go na oslep, nie czytaja tekstu poradnika ktory opisuje dodatkowe kroki i pozniej dostaje setki maili, wiadomosci i komentaezy ze cos nie dziala. Nie dajac gotowego kodu zmuszam do przeczytania tekstu.

      Ludzie roznie sie lubia uczyc i nie zadowole wszystkich.

    • Krzysztof Prus

      A teraz nie dostajesz setki maili i komentarzy? :P

    • Owszem mam, ale wynikaja z tego ze poradni ma 3 lata, byl pisany pod Unity 4.0 potem dostosowywany pod Unity 4.6 a obecnie wszyscy maja wersje powyzej 5 i czesci funkcji, obiektow, komponentow etc. Zwyczajnie nie ma, bo zostaly suniete albo zastapione czym innym.

  • lisekmiki

    Mam problem po pełnym zmodyfikowaniu skryptu Moving
    nie wyskakuje żaden błąd ale gdy włączę grę kliknę LANHost
    i na odpalonej grze na tym samym kompie Kliknę LAN Client z dopiskiem localhost to po pierwsze Client nie łączy się z hostem a po drugie Kostka po oddaleniu się od środka zaczyna wariować pomocy!

    • Uruchamiasz przez dwa edytory, czy budujesz projekt i łączysz się między edytorem i buildem?

    • lisekmiki

      buduje projekt i łącze się z edytorem

    • Dzieje się tak po dodaniu kolejkowania czy już po pierwszej części tego poradnika?

    • lisekmiki

      po dodaniu kolejkowania wcześniej było ok

    • Możesz pokazać swój kod (najlepiej przez pastebin)? :)

    • lisekmiki
    • Widzę kilka błędów:
      1) masz trochę za dużo zmiennych:
      U Ciebie:
      [SyncVar] Color color;
      [SyncVar] CubeState state;
      Queue pendingMoves;
      CubeState predictedState;
      [SyncVar(hook=”OnServerStateChanged”)] CubeState serverState;

      Wystarczy:
      [SyncVar] Color color;
      Queue pendingMoves; // new
      CubeState predictedState;
      [SyncVar(hook=”OnServerStateChanged”)] CubeState serverState;

      2) Masz błąd w funkcji Move:
      Dokładnie tutaj:
      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;
      U Ciebie dla DownArrow i LeftArrow brakuje minusów.

      3) Funkcja CMdMoveOnServer:
      [Command(channel=0)]
      void CmdMoveOnServer(KeyCode arrowKey)
      {
      serverState = Move(serverState, arrowKey);
      }

      U Ciebie jako parametr jest nie serverState tylko state.

      Tyle błędów wyłapałem. Popraw je, jeśli coś dalej będzie nie tego, to będziemy szukać. ;)

      Przy deklaracji zmiennych usuniesz zmienną state, więc jak gdzieś w kodzie, będzie ona jeszcze występować, to sprawdź z poradnikiem jaka powinna być prawidłowa zmienna w danym miejscu. :)

    • lisekmiki

      Już wszystko Ok :)

  • Scorpion_1982

    Jeśli jest takie lagowanie po lanie bez limitu przesyłu to pięknie to musi śmigać po za siecią Lan, gdy by doszło 20 osób to będzie masakra.
    Lepiej jest rozwijać swoją pracę niż cały czas poprawiać po sobie. Mam nadzieję że dalsze lekcje są lepsze.

  • adwadwa

    endingMoves mi się podkreśla na czerwono wszystkie, co mam zrobić?

    • adwadwa

      pendingMoves;

    • adwadwa

      Proszę o pomoc :)

    • Pokaż swój kod najlepiej w pastebin oraz podaj dokładną treść błędów, bez tego nawet nie wiem o co chodzi ;) I wystarczy jeden komentarz, czytam wszystko, ale fizycznie nie jestem w stanie odpowiadać zaraz po pojawieniu się komentarza.

    • adwadwa

      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; <—- dlaczego tutaj nie ma skasowałem to i chyba jest dobrze :D pomocy
      CubeState predictedState;
      [SyncVar] CubeState serverState;