Unity3d QuickTip – czyli szybkie porady, rozwiązania częstych problemów i sztuczki w Unity3d!
Dzisiejszy odcinek: Multiplayer w Unity3d z wykorzystaniem UNet. Część 1
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
Teoria
Czym jest multiplayer chyba nie muszę tłumaczyć. W tym przykładzie będziemy korzystać z wprowadzonego w nowym Unity systemu UNet, który pozwala uruchomić grę w trybie multiplayer w bardzo szybki i prosty sposób.
Z racji, że omawiamy tutaj bardziej zaawansowane zagadnienia, nie będę opisywał rzeczy typu jak dodać Cube, albo jak importować paczkę assetów. Zakładam, że użytkownik podejmujący się próby napisania trybu multiplayer potrafi takie rzeczy.
Warto też wiedzieć jak działa mniej więcej sieć komputerowa i czym jest architektura klient – serwer (client – server). Nie chodzi tu o dokładne rozeznanie w architekurze TCP-IP, ale o podstawową wiedzę czym jest owy klient i czym jest serwer.
Przygotowanie
Aby zacząć przygodę z multiplayerem, najpierw musimy wykonać trochę kodu i czynności pod single player. Streszczając, potrzebujemy jakiejś podstawowej mechaniki. Od razu muszę tutaj zmartwić osoby, które spodziewały się, że po tym poradniku będą miały gotowego Counter-Strike’a. Pokażę tutaj podstawowe zasady budowania gry Multiplayer z wykorzystaniem UNet. Przyłączenie tego mechanizmu do waszej gry, pozostawiam wam.
W sumie wszystko co nam będzie na razie potrzebne, to prosty Cube, do którego dopiszemy sobie skrypt. U mnie będzie to Moving.cs. Skrypt dopisujemy do utworzonego Cuba (przeciągając skrypt na niego). Wchodzimy w edycję skryptu i wprowadzamy tam coś takiego:
using UnityEngine; public class Moving : MonoBehaviour { struct CubeState { public float x; public float y; public float z; } CubeState state; void Awake() { InitState(); } void InitState() { state = new CubeState { x = 0, y = 0, z = 0 }; } void Update() { KeyCode[] arrowKeys = { KeyCode.UpArrow, KeyCode.DownArrow, KeyCode.RightArrow, KeyCode.LeftArrow }; foreach (KeyCode arrowKey in arrowKeys) { if (!Input.GetKey(arrowKey)) continue; state = Move(state, arrowKey); } SyncState(); } void SyncState() { transform.position = new Vector3(state.x, state.y, state.z); } 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 { x = dx + previous.x, y = dy + previous.y, z = dz + previous.z }; }
No to wyjaśnimy sobie na szybko bardziej zawiłe elementy i rzeczy na które trzeba zwrócić uwagę.
struct CubeState { public float x; public float y; public float z; } CubeState state;
Tutaj mamy prostą strukturę, do przechowywania pozycji naszej kostki w świecie gry. Funkcję Awake i wywoływaną w niej funkcję pomijam, bo to jedynie inicjacja początkowych wartości.
void Update() { KeyCode[] arrowKeys = { KeyCode.UpArrow, KeyCode.DownArrow, KeyCode.RightArrow, KeyCode.LeftArrow }; foreach (KeyCode arrowKey in arrowKeys) { if (!Input.GetKey(arrowKey)) continue; state = Move(state, arrowKey); } SyncState(); }
W funkcji Update sprawdzamy sobie wszystkie klawisze strzałek. Jeśli któryś jest wciśnięty, to pod nasz obecny stan kostki (jej pozycję) podstawiamy sobie wynik funkcji Move. Na końcu każdej klatki synchronizujemy stany.
void SyncState() { transform.position = new Vector3(state.x, state.y, state.z); }
Synchronizacja to nic innego jak przypisanie do naszej aktualnej pozycji, wartości z zapisanego stanu. Na koniec została funkcja Move. Jej nie rozpisuję, bo jedyne co tam robimy, to określamy który klawisz został wciśnięty i na tej podstawie modyfikujemy naszą zmienną state, zwracając aktualny stan kostki. Jak wiemy z wcześniejszego kawałku kodu, następnie funkcja synchronizacji przypisze nowy stan, do faktycznej pozycji kostki.
Jeśli teraz uruchomisz sobie grę, to za pomocą strzałek możesz normalnie ruszać kostką.
Magia UNet i Multiplayer
Ale przyszliśmy tu pracować nad Multiplayerem, prawda? Środowisko UNet jest bardzo przyjemne i proste w obsłudze, ponieważ opiera się na gotowych komponentach, które wystarczy odpowiednio przypisać i skonfigurować. Do tego wystarczy kilka zmian w kodzie i będziemy się cieszyć naszą grą multiplayer.
Pierwsze co musimy sobie zrobić, to umożliwić graczom na połączenie z serwerem. Posłużą nam do tego specjalne komponenty. Tworzymy sobie pusty GameObject (Ctrl + Shift + N). Nazywamy go sobie dowolnie (u mnie jest to NetworkManager). Do obiektu dodajemy dwa komponenty: NetworkManager i NetworkManagerHUD. Oba są dostępne z poziomu: [Component -> Network].
Na szybko wyjaśnimy czym są oba komponenty. Pierwszy (NetworkManager) to główny komponent zarządzający połączeniem. Dzięki niemu możemy przesyłać komunikaty między klientem i serwerem, tworzyć serwery etc.
NetworkManagerHUD pozwala nam wyświetlić proste menu to tworzenia gier multiplayer.
Teraz czas na szok. Tyle wystarcza, żeby instancja gry mogła utworzyć serwer (zostać hostem), albo inna mogła się podłączyć do tego serwera jako klient. Ale samo połączenie to nie wszystko, bo nasz obiekt gracza nie jest przystosowany do bycia obiektem multiplayer.
Przystosowanie gracza do Multiplayer
Pierwsze co należy zmienić do dodać do naszego obiektu gracza (kostki) tożsamość sieciową – brzmi dziwnie, ale chodzi o to, żeby poinformować sieciowe API, że należy się zaopiekować wartościami sieciowymi tego obiektu. Mówiąc bardziej obrazowo. Jeśli mamy MMO to dla wszystkich graczy budynki, ziemia itp. są w tym samym miejscu i się nie ruszają. Za to przeciwnicy i sami gracze przemieszczają się. Dlatego budynki nie muszą mieć tożasmości sieciowej, bo wszędzie i zawsze będą w tym samym miejscu, więc można odciążyć łącze i wyświetlić je graczowi lokalnie. Za to przeciwnicy i inni gracze muszą mieć tożsamość, aby serwer mógł im synchronizować pozycję, dzięki czemu u każdego gracza dany przeciwnik jest w tym samym miejscu i robi to samo.
To jak to osiągnąć? Również dość prosto – przynajmniej na początku. Wracamy do naszej kostki (zaznaczamy ją) i dodajemy do niej komponent NetworkIdentity. (Znów menu: [Component -> Network -> NetworkIdentity]). Serio, tyle wystarczy.
Teraz musimy zmienić nasz obiekt gracza na prefab (wystarczy przeciągnąć go ze sceny do panelu Project). Obiekt znajdujący się na scenie możemy już usunąć.
Wybieramy sobie ponownie nasz NetworkManger i znajdujemy w nim opcję PlayerPrefab, gdzie przeciągamy nasz przed chwilą utworzony prefab.
Jesteśmy bardzo blisko, ale to jeszcze nie zadziała, bez lekkiej modyfikacji kodu.
UNet NetworkBehaviour
Wracamy do edycji naszego skryptu. Pierwsza zmiana to podmiana legendarnego MonoBehaviour na NetworkBehaviour:
public class Moving : NetworkBehaviour
NetworkBehaviour jest w sumie tym co MonoBahaviour ale wzbogacone o funkcje umożliwiające wymianę danych między klientem i serwerem. Jednak sama podmianka nie załatwi wszystkiego, bo na samej górze musimy sobie dodać coś takiego:
using UnityEngine; using UnityEngine.Networking;
Bez bibliotek sieciowych, zwyczajnie sieciowej gry nie zrobimy. Kolejna zmiana to dodanie słowa kluczowego [SyncVar] do naszego stanu kostki:
[SyncVar] CubeState state;
Co daje SyncVar? Zapewnia nam to, że jeśli wartość zmiennej po [SyncVar] zostanie zmieniona na serwerze, to serwer rozsyła informację z nową wartością do wszystkich podłączonych klientów. Należy tutaj pilnować, żeby wszystkie zmiany zmiennych z ustawionym [SyncVar] dokonywane były z poziomu hosta.
Jeżeli teraz przetestowalibyśmy grę, okazałoby się, że jeśli naciśniemy strzałkę, to wszystkie podpięte kostki wykonają ruch. Trochę bez sensu, ale żeby to ogarnąć wystarczy prosta zmiana:
void Update() { if (isLocalPlayer) { KeyCode[] arrowKeys = { KeyCode.UpArrow, KeyCode.DownArrow, KeyCode.RightArrow, KeyCode.LeftArrow }; foreach (KeyCode arrowKey in arrowKeys) { if (!Input.GetKey(arrowKey)) continue; state = Move(state, arrowKey); } } SyncState(); }
isLocalPlayer to zmienna mówiąca o tym, czy dany obiekt gracza (czyli prefab, który dodaliśmy wcześniej do NetworkManagera) to nasz obiekt, czy nie. Innymi słowy, ten if sprawia, że sprawdzamy stan naciśnięcia klawiszy i dokonujemy ewentualnego ruchu, tylko wtedy kiedy mamy do czynienia z naszym własnym obiektem gracza.
No dobra, ale powiedzieliśmy sobie, że zmienne z [SyncVar] powinny być aktualizowane tylko przez serwer, co aktualnie nie ma miejsca. Więc musimy coś z tym zrobić. UNet znów dostarcza do tego wygodne narzędzie jakim są komendy (Commands). Jest to nic innego jak funkcja na serwerze, którą można wywołać z poziomu klienta.
Zasada zmiany funkcji w komendę jest bardzo prosta. Znów dodajemy sobie słowo kluczowe [Command] przed nazwą funkcji, a do jej nazwy dopisujemy przedrostem Cmd. Żeby nie paskudzić dodatkowo kodu, tworzymy sobie dodatkową funkcję:
[Command] void CmdMoveOnServer(KeyCode arrowKey) { state = Move(state, arrowKey); }
Jak widać, nie ma tutaj jakichś cudów. Robi ona dokładnie to, co wcześniej robiliśmy w funkcji Update przyjmując tylko naciśnięty klawisz. Robimy jeszcze jedną zmianę:
void Update() { if (isLocalPlayer) { KeyCode[] arrowKeys = { KeyCode.UpArrow, KeyCode.DownArrow, KeyCode.RightArrow, KeyCode.LeftArrow }; foreach (KeyCode arrowKey in arrowKeys) { if (!Input.GetKey(arrowKey)) continue; CmdMoveOnServer(arrowKey); } } SyncState(); }
Więc co się właściwie stało? Technicznie wygląda jak byśmy nic nie zrobili. Ale różnica jest znaczna. Teraz, gdy gracz naciśnie strzałkę, to zamiast modyfikować zmienną stanu, wywołujemy sobie funkcję CmdMoveOnServer podając jej, jaki klawisz został naciśnięty. Zaś sama funkcja znajduje się już na serwerze i to ona wykonuje obliczenia jak zmieniła się pozycja postaci. Osiągnęliśmy to co chcieliśmy, czyli teraz tylko serwer modyfikuję zmienną state, która ma [SyncVal].
Warto tutaj zwrócić uwagę na jeszcze jeden fakt. Nasze wywołanie ruchu wykonujemy dalej wewnątrz ifa z isLocalPlayer, dzięki czemu zmianę pozycji wykonujemy tylko dla obiektów, które należą do gracza. Dzięki temu uniemożliwiamy graczowi przejęcie kontroli nad obiektami innego gracza, a dodatkowo na serwerze mamy pewność kto wywołał daną akcję, co znacząco zwiększa bezpieczeństwo naszej gry sieciowej.
Został nam ostatni problem, a mianowicie funkcja InitState, która inicjuje początkowe wartości zmiennej state. Czemu to jest problem? Ponownie zmienna state posiadająca atrybut [SyncVal] może być modyfikowana tylko przez serwer. Tutaj znów nas ratuje UNet, ze swoim kolejnym atrybutem [Server]
[Server] void InitState() { state = new CubeState { x = 0, y = 0, z = 0 }; }
Dzięki temu, funkcja wywoła się na serwerze. W przypadku, gdy funkcja zostaje wywołana na kliencie, wywołanie zostaje zignorowane.
Na razie tyle.
Jak testować?
Zostało zasadnicze pytanie. Jak to teraz przetestować? Jest w sumie jeden sposób. Należy zrobić sobie build gry [File -> Build Settings]. Jeśli w okienku Scenes In Build nie ma ani jednej sceny, można skorzystać z przycisku Add Open Scene. (Jeśli do tej pory nie zapisaliśmy sceny teraz będzie trzeba to zrobić). Potem klikamy klawisz Build i czekamy. Gdy mamy gotowy build odpalamy sobie grę, wybieramy tam: LAN Host. Następnie uruchamiamy grę z edytora Unity, klikając Play i z menu, które się pojawi wybieramy LAN Client.
Teraz pewnie rzuca się w oczy jedna rzecz. Mamy straszne lagi. Wynika to z opóźnień. Zanim wyślemy komunikat o kliknięciu klawisza do serwera, zanim on obliczy nową wartość i ponownie ją zwróci mija niezerowy czas. Przez to pojawiają się lagi. Są na to metody i jedną z nich omówimy sobie kolejnym razem.