Всем привет! Сегодня хотелось бы рассказать про один из способов, как можно создать локальный мультиплеер в Unity. Данное решение подходит для шоукейсов, теста фич или локального мультиплеера. К примеру, если вам хочется видеть, что делает игрок, но не хочется скажем на андроиде тратить лишние ресурсы и забирать скринкаст с помощью ADB, то можно просто поднять сервер на какой-то машинке в виде копии приложения, которое работает на телефоне, и слать туда информацию о действиях игрока.
Я вкратце опишу, как можно это сделать с помощью HLAPI на Unet, но не через NetworkingManager, а чуть более низкоуровнево. По доброй традиции приложу пример своей реализации такого клиент-серверного взаимодействия. Пример прошу не судить строго, так как я прекрасно понимаю, что архитектура данного решения никуда не годится и создаёт кучу проблем в перспективе. Моей целью было максимально быстро (за выходные) написать систему, в которой можно показать принцип работы с сетью. Также скажу с какими проблемами пришлось столкнуться. В данном примере реализации предполагается, что сервер так же является Unity приложением.
В интернете самым частым примером того, как сделать мультиплеер является чат, но я люблю игры, и делать чат мне показалось скучным. Поэтому я решил разобрать то, как можно сделать мультиплеер на примере крестиков-ноликов. Допустим, что вся логика игры, определения победителя, смены ходов и т. п. написаны, и нам осталось прикрутить сервер. В простейшем случае в крестиках-ноликах нам надо обрабатывать 2 сообщения. Определение порядка хода (раздача айдишников) и захват клетки.
В целом игра в данном примере работает очень просто. Есть 2 сцены. Загрузочная и геймплейная. При загрузке геймплейной сцены у нас генерируется поле на котором играют игроки. Там же происходит проверка условия победы, есть классы отвечающие за работу UI, а так же порядок ходов и в целом логику игры, но нам это не особо интересно. Основные классы, которые отвечают за сетку — это Server, Client, NetManager и отдельный файл для сообщений NetMessages и определённый в нём enum MyMsgType. Они представляют из себя обёртку над средствами Unet. С точки зрения Unet основные классы, которые мы будем использовать — это NetworkClient, NetworkServer, MessageBase и MsgType. Что это за классы?
Самые простые классы — это MessageBase и MsgType. Первый — это абстрактный класс, от которого надо наследовать все наши сообщения, чтобы пересылать их между клиентом и сервером. MsgType — это просто класс, который хранит в себе константы, отвечающие за определённый набор вшитых в Unet сообщений.
NetworkServer — это синглтон, который предоставляет возможность обрабатывать общение с удалёнными клиентами. Под капотом он использует экземпляр NetworkServerSimple и по сути представляет из себя удобную обёртку над ним. Для начала нам надо запустить сервер на определённом порте. Для этого необходимо вызвать метод Listen(int serverPort) — этот метод запускает сервер на порте serverPort. (Напомню, что все порты в диапазоне от 0 до 1023 являются системными и их не стоит использовать в качестве параметра данного метода)
Отлично сервер работает и слушает какой-то порт. Теперь нужно, чтобы он реагировал на сообщения. Для этого нужно зарегистрировать хэндлер с помощью метода RegisterHandler(short msgType, Networking.NetworkMessageDelegate handler). Данный метод принимает параметром тип сообщения и делегат. Делегат в свою очередь должен принимать входным параметром NetworkMessage. Допустим мы хотим, чтобы в тот момент, когда к нему присоединялся игрок на сервере начала загружаться геймплейная сцена, а так же раздавались айдишники игроков. Тогда нужно зарегистрировать хэндлер на соответствующее сообщение, а так же реализовать метод, который мы будем передавать в качестве делегата для регистрации.
Выглядит это примерно так:
NetworkServer.RegisterHandler(MsgType.Connect, OnConnect);
private void OnConnect(NetworkMessage msg)
{
Debug.Log(string.Concat("Connected: ", msg.conn.address));
var connId = msg.conn.connectionId;
if (NetworkServer.connections.Count > Constants.PLAYERS_COUNT)
{
SendPlayerID(connId, -1);
}
else
{
int index = Random.Range(0, Constants.PLAYERS_IDS.Length);
SendPlayerID(connId, Constants.PLAYERS_IDS[index]);
_CurrentUser.PlayerID = Constants.PLAYERS_IDS[(index + 1) % Constants.PLAYERS_COUNT];
SceneManager.LoadScene(1);
}
}
Теперь метод OnConnect будет вызываться при каждом коннекте какого-то пользователя. Стоит уточнить, что в данной реализации айдишники определяют порядок хода, поэтому при первом коннекте выбираются айдишники для клиента и сервера. Остальные клиенты автоматом получают айди равный -1, что означает, что этот клиент является зрителем.
Простой сервер есть. Теперь бы клиенты не помешали. Для этого воспользуемся классом NetworkClient. Для того, чтобы присоединиться к серверу надо просто вызвать метод Connect(string serverIp, int serverPort). В качестве порта мы устанавливаем порт, который слушает наш сервер, в качестве айпи ставим localhost, если мы тестируем наше приложение на одной машине или же айпи компа в локальной сети, который мы используем в качестве сервера (Узнать его можно или в настройках сети, или в консоли с помощью команды ipconfig на том компе, который будет выступать в роли сервера).
Замечательно, мы может подконнектиться. Ранее говорилось, что сервак у нас раздаёт айдишники. Значит нам надо во-первых, послать сообщение о айдишнике, а так же зарегистрировать хэндлер этого сообщения на клиенте. Как упоминалось ранее все сообщения надо наследовать от MessageBase. Для начала определим их (я предпочитаю это делать в отдельном файле):
public class PlayerIDMessage : MessageBase
{
public int PlayerID;
}
public enum MyMsgType : short
{
PlayerID = MsgType.Highest + 1,
}
Чтобы послать данное сообщение вызываем на клиенте метод Send(short msgType, Networking.MessageBase msg), который отправит сообщение msg «типа» msgType серверу, или на сервере один из методов в зависимости от цели SendToAll(short msgType, Networking.MessageBase msg) или SendToClient(int connectionId, short msgType, Networking.MessageBase msg), где connectionId — это id определённого клиента.
Чтобы читать наши кастомные сообщения пришедшие нам от другого приложения пользуемся reader.ReadMessage(). Например:
private void OnPlayerID(NetworkMessage msg)
{
PlayerIDMessage message = msg.reader.ReadMessage<PlayerIDMessage>();
_CurrentUser.PlayerID = message.PlayerID;
SceneManager.LoadScene(1);
}
В принципе всё. Дальнейшее использование зависит от конкретики. Создаём нужные нам сообщения в зависимости от геймплея и отправляем их. В крестиках-ноликах я так же определил ещё одно сообщение, которое отвечает за захват точки. Целиком реализацию проекта можно посмотреть тут.
О чём ещё хочется сказать, и на что пришлось потратить время, так это пару вещей, которые вам могут быть не очевидны, если вы не работали с сетями.
1. Проверьте, что редактор юнити не заблокирован вашим фаерволом для общения по протоколам TCP и UDP. Однажды я потратил на это некоторое время, при том, что добрался до фаерволла и поставил нужный порт в исключения, но не проверил то, что редактор незаблокирован.
2. Передавайте в сообщениях value-type. Reference-type будут передавать чушь, так как по адресу который вы хотите передать в другую аппу, неизвестно что лежит. (Я думаю, что это и так понятно тем, кто понимает, как работают value и reference type)
В случае, когда мы говорим про локальную сеть и заранее известное железо, нет многих проблем возникающих в случае “настоящего” мультиплеера. Практически не нужно задумываться о задержках, флуктуациях, потерях пакетов и прочем. Поэтому такое решение может быть полезно, хотя и эти проблемы можно решать с таким подходом до некоторого предела. В сравнении с более высоким уровнем абстракции в Unet, через NetworkManager, NetworkBehavior и т.п., оно предоставляет более понятную и очевидную гибкость (на мой взгляд), если на клиентах требуется загружать разные сцены и т. п., а скажем сервер используется, как стриминг, и показывает то, что отображается на девайсе игрока, принимая его позицию и поворот + дублируя происходящее на своей стороне. В остальных случаях, когда необходимо решение быстрее и вы умеете работать с сетью, Unet предоставляет возможность писать на транспортном уровне.
Так же хочется уточнить про решение на гитхабе (не с точки зрения инструментов, а с точки зрения подхода к архитектуре) на мой взгляд, это пример — как не стоит делать. Не говоря о самой архитектуре игры проблема в том, что логика на клиенте и на сервере считается независимо. При реализации адекватного мультиплеера с клиент-серверной архитектурой лучше, когда состояние игры хранится на сервере и реплицируется на клиенты, а клиенты посылают команды, которые изменяют состояние игры на сервере. Это конечно так же зависит от многих факторов, но в среднем такой подход.
Продублирую тут ссылку на решение.
Спасибо за внимание!
Автор: DyadichenkoGA