Основы многопользовательской игры на Unity3D

в 10:04, , рубрики: game development, multiplayer, network, unity3d, многопользовательские игры, метки: , , , ,
Основы многопользовательской игры на Unity3D

Привет!

Я, как и многие из вас, большой поклонник многопользовательских игр. В них меня прельщает в основном дух соревнования и возможность приобретать улучшения, накапливая достижения. Да и сама идея выхода в свет все большего количества игр данного типа побуждает к действию.
С недавнего времени я и сам взялся за разработку собственного проекта. И поскольку на Хабрахабре статей на эту тематику не нашел – решил поделиться своим опытом написания многопользовательской игры на движке Unity3D. Также хочу рассказать о компонентах Network и NetworkView, атрибуте RPC и встроенных методах-ивентах. В конце статьи подан пример игры и, разумеется, сам проект для Unity. Итак…

Класс Network

Данный класс нужен для организации соединения «клиент-сервер». Основные функции: создание сервера, подключение к серверу, создание сетевого экземпляра префаба.

Основные методы:

Network.Connect (string host, int remotePort, string password = "") – выполняет подключение к серверу host с портом remotePort и паролем password. Метод возвращает перечисление NetworkConnectionError.

Network.InitializeServer(int connections, int listenPort, bool useNat) – создает сервер с максимально разрешенным количеством подключений connections; порт входящих подключений listenPort, а также useNat: использовать либо нет NAT. Также возвращает перечисление NetworkConnectionError.

Network.InitializeSecurity() – вызывается перед Network.InitializeServer() для защиты от читерства. Подробности в официальной документации. Не вызывать на клиенте!

Network.Instantiate(Object prefab, Vector3 position, Quaternion rotation, int group) – создает экземпляр префаба prefab в сети в позиции position с поворотом rotation и группой group. Возвращает весь созданный объект, с которым после создания можно выполнить дополнительные действия. Подробности – далее в статье.

Основные свойства:

bool Network.isClient и bool Network.isServer – определяют, является ваша игра сервером либо клиентом. Оба свойства являются false, если не был создан сервер или не было подключения к серверу.

string Network.incomingPassword – свойство задает пароль для входящих подключений.

NetworkPlayer Network.player – возвращает экземпляр локального игрока NetworkPlayer.

NetworkPeerType Network.peerType – возвращает текущее состояние подключения: Disconnected (отключен), Server (запущен как сервер), Client (подключен к серверу), Connecting (попытка, в процессе подключения).

NetworkPlayer[] Network.connections – возвращает всех подключенных игроков. На клиенте возвращает только игрока сервера.

Основные ивенты (для унаследованного от MonoBehaviour):

OnConnectedToServer() – вызывается на клиенте при успешном подключении к серверу.

OnDisconnectedFromServer(NetworkDisconnection info) – вызывается на клиенте при отключении от сервера и на сервере при завершении подключений Network.Disconnect(). В info содержится причина отключения: LostConnection (потеря связи) и Disconnected (при успешном отключении).

OnFailedToConnect(NetworkConnectionError error) — вызывается на клиенте при ошибке подключения. error содержит ошибку типа NetworkConnectionError.

OnNetworkInstantiate(NetworkMessageInfo info) — вызывается на клиенте и сервере, если был создан новый экземпляр методом Network.Instantiate(). Содержит info типа NetworkMessageInfo.

OnPlayerConnected(NetworkPlayer player) — вызывается на сервере при успешном подключении клиента и содержит player типа NetworkPlayer.

OnPlayerDisconnected(NetworkPlayer player) — вызывается на сервере при отключении клиента и содержит player типа NetworkPlayer.

OnServerInitialized() — вызывается на сервере, после того как сервер был успешно создан.

OnSerializeNetworkView(BitStream stream, NetworkMessageInfo info) — важный ивент для синхронизации компонента с сетью. Подробности – далее в статье.

Класс NetwokView

Данный класс существует также и как компонент для Unity, и предназначен он для синхронизации компонентов в сети и для вызова RPC.
Обладает такими свойствами синхронизации NetworkStateSynchronization:

  • Off — не выполняет синхронизацию объекта, однако позволяет вызывать удаленные процедуры.
  • ReliableDeltaCompressed — выполняет передачу пакетов поочередно и проверяет, доставлен ли пакет (подобно протоколу TCP).
  • Unreliable — выполняет быструю отправку пакетов, не гарантируя доставки (подобно протоколу UDP).
Основные методы:

networkView.RPC(string name, RPCMode mode, params object[] args) — вызывает удаленную процедуру name, mode определяет получателей, args – аргументы для передачи процедуре.

networkView.RPC(string name, NetworkPlayer target, params object[] args) – то же, что и предыдущий метод, однако выполняет отправку конкретному игроку NetworkPlayer.

Основные свойства:

bool networkView.isMine – свойство, определяющее, является ли объект локальным. Весьма часто используется для проверки владельца объекта.

Component networkView.observed – компонент, который будет синхронизироваться. Если это скрипт, то он должен содержать метод OnSerializeNetworkView(BitStream stream, NetworkMessageInfo info), упомянутый выше.

NetworkPlayer networkView.owner – свойство, возвращающее владельца объекта.

NetworkStateSynchronization networkView.stateSynchronization — тип синхронизации: Off, ReliableDeltaCompressed, Unreliable.

NetworkViewID networkView.viewID — уникальный идентификатор в сети для NetworkView.

Атрибут RPC

Согласно данным из Википедии RPC — класс технологий, позволяющих компьютерным программам вызывать функции или процедуры в другом адресном пространстве (как правило, на удалённых компьютерах).
Атрибут используется для назначения метода, вызываемого из сети. Для его функционирования необходимо добавить компонент NetworkView.

Метод OnSerializeNetworkView(BitStream stream, NetworkMessageInfo info)

Данный метод используется для синхронизации компонента в сети. Он вызывается всякий раз при получении либо отправке данных по сети.
Вот типы данных, которые могут быть получены/отправлены методом Serialize: bool, char, short, int, float, Quaternion, Vector3, NetworkPlayer, NetworkViewID.
Для проверки, идет ли прием либо передача, используются свойства isReading или isWriting.

Привожу пример использования:

void OnSerializeNetworkView(BitStream stream, NetworkMessageInfo info) {
	Vector3 syncPosition = Vector3.zero; // обязательно должен быть инициализирован перед приемом
	if (stream.isWriting) {
		syncPosition = rigidbody.position; // считываем текущую позицию
		stream.Serialize(ref syncPosition); // синхронизируем с сетью
	} else {
		stream.Serialize(ref syncPosition); // получаем позицию из сети
		rigidbody.position = syncPosition; // записываем позицию в наш объект.
	}
}

Данный пример не идеален, поскольку при его работе наши объекты будут «дергаться». Чтобы избежать этого, нужно воспользоваться интерполяцией. Подробнее – далее в статье.

Интерполяция

Суть интерполяции заключается в том, что между чтением положения из сети мы плавно перемещаем наш объект через функцию Lerp во время обновления экрана.
Берем текущую позицию как начало и синхронизированную — как конец, и по мере обновления кадров перемещаем наш объект.

Основы многопользовательской игры на Unity3D

Подробнее о методах оптимизации синхронизации по сети смотрите на сайте разработчиков: Valve Developer Community — Source Multiplayer Networking

Пример многопользовательской игры

Итак, имея представления об основах, можно приниматься за написание небольшой многопользовательской игры. В качестве примеров я использую разные способы применения NetworkView. Вам остается лишь выбрать для себя наиболее удобный способ.

Создаем скрипт ServerSide.cs и пишем туда следующее:

using UnityEngine;
using System.Collections;

[RequireComponent( typeof( NetworkView ) )] // сообщает Unity о том, что нам нужен компонент NetworkView. Данному компоненту NetworkStateSynchronization можно выставить Off.

public class ServerSide : MonoBehaviour {
	private int playerCount = 0; // хранит количество подключенных игроков
	public int PlayersCount { get { return playerCount; } } // публичный доступ для внешних компонентов относительно количества игроков на сервере

    void OnServerInitialized() {
        SendMessage( "SpawnPlayer", "Player Server" ); // создаем локального игрока сервера
    }

    void OnPlayerConnected( NetworkPlayer player ) {
		++playerCount; // при подключении каждого нового игрока увеличиваем количество подключенных игроков
        networkView.RPC( "SpawnPlayer", player, "Player " + playerCount.ToString() ); // вызываем у игрока процедуру создания экземпляра префаба
    }

    void OnPlayerDisconnected( NetworkPlayer player ) {
        --playerCount; // уменьшаем количество игроков
        Network.RemoveRPCs( player ); // очищаем список процедур игрока
        Network.DestroyPlayerObjects( player ); // уничтожаем все объекты игрока
    }
}

Теперь создаем скрипт клиента ClientSide.cs:

using UnityEngine;
using System.Collections;

[RequireComponent( typeof( NetworkView ) )] // сообщает Unity о том, что нам нужен компонент NetworkView. Данному компоненту NetworkStateSynchronization можно выставить Off.

public class ClientSide : MonoBehaviour {
    public GameObject playerPrefab; // префаб игрока, который будет создан в процессе игры
    public Vector2 spawnArea = new Vector2( 8.0f, 8.0f ); // зона спауна
    
	private Vector3 RandomPosition { // случайная позиция в зоне спауна
        get {
            return transform.position +
                    transform.right * ( Random.Range( 0.0f, spawnArea.x ) - spawnArea.x * 0.5f ) +
                    transform.forward * ( Random.Range( 0.0f, spawnArea.y ) - spawnArea.y * 0.5f );
        }
    }

    [RPC] // сообщает Unity о том, что данный метод можно вызвать из сети
    private void SpawnPlayer( string playerName ) {
        Vector3 position = RandomPosition; // делаем случайную позицию создания персонажа
        GameObject newPlayer = Network.Instantiate( playerPrefab, position, Quaternion.LookRotation( transform.position - position, Vector3.up ), 0 ) as GameObject; // создаем нового персонажа в сети
        newPlayer.BroadcastMessage( "SetPlayerName", playerName ); // задаем созданному персонажу имя (оно будет автоматически синхронизировано по сети)
    }
	
	void OnDisconnectedFromServer( NetworkDisconnection info ) {
        Network.DestroyPlayerObjects( Network.player ); // удаляемся из игры
    }
}

Таким образом, клиентская и серверная логика есть, теперь для нее нужно сделать управление MainMenu.cs:

using UnityEngine;
using System.Collections;

public class MultiplayerMenu : MonoBehaviour {
    const int NETWORK_PORT = 4585; // сетевой порт
    const int MAX_CONNECTIONS = 20; // максимальное количество входящих подключений
    const bool USE_NAT = false; // использовать NAT?

    private string remoteServer = "127.0.0.1"; // адрес сервера (также можно localhost)

    void OnGUI() {
        if ( Network.peerType == NetworkPeerType.Disconnected ) { // если не подключен
            if ( GUILayout.Button( "Start Server" ) ) { // кнопка «запустить сервер»
                Network.InitializeSecurity(); // инициализируем защиту
                Network.InitializeServer( MAX_CONNECTIONS, NETWORK_PORT, USE_NAT ); // запускаем сервер
            }
            GUILayout.Space(30f); // отступ
            remoteServer = GUILayout.TextField( remoteServer ); // поле адреса сервера
            if ( GUILayout.Button( "Connect to server" ) ) { // кнопка «подключиться»
                Network.Connect( remoteServer, NETWORK_PORT ); // подключаемся к серверу
            }
        } else if ( Network.peerType == NetworkPeerType.Connecting ) { // во время подключения
            GUILayout.Label( "Trying to connect to server" ); // выводим текст
        } else { // в остальных случаях ( NetworkPeerType.Server, NetworkPeerType.Client)
            if ( GUILayout.Button( "Disconnect" ) ) {  // кнопка «отключиться»
                Network.Disconnect(); // отключаем всех клиентов либо отключаемся от сервера
            }
        }
    }

    void OnFailedToConnect( NetworkConnectionError error ) {
        Debug.Log( "Failed to connect: " + error.ToString() ); // при ошибке подключения к серверу выводим саму ошибку
    }

    void OnDisconnectedFromServer( NetworkDisconnection info ) {
        if ( Network.isClient ) {
			Debug.Log( "Disconnected from server: " + info.ToString() ); // при успешном либо неуспешном отключении выводим результат
        } else {
            Debug.Log( "Connections closed" ); // сообщение выводится при выключении сервера Network.Disconnect()
        }
    }

    void OnConnectedToServer() {
        Debug.Log( "Connected to server" ); // сообщение выводится при успешном подключении к серверу
    }
}

Управление сетью создано. Далее пишем управление игроком PlayerControls.cs. В данном примере я использую другой способ применения компонента NetworkView:

using UnityEngine;
using System.Collections;

[RequireComponent( typeof( Rigidbody ) )] // в данном примере нам понадобится Rigidbody

public class PlayerControls : MonoBehaviour {
	/* для интерполяции */
    private float lastSynchronizationTime; // последнее время синхронизации
    private float syncDelay = 0f; // дельта между текущим временем и последней синхронизацией
    private float syncTime = 0f; // время синхронизации

    private Vector3 syncStartPosition = Vector3.zero; //начальная позиция интерполяции 
    private Vector3 syncEndPosition = Vector3.zero; // конечная позиция интерполяции

    private Quaternion syncStartRotation = Quaternion.identity; // начальный поворот интерполяции
    private Quaternion syncEndRotation = Quaternion.identity; // конечный поворот интерполяции

    private NetworkView netView; // компонент NetworkView
	
	private string myName = ""; // наше имя (для примера, мы его не используем)
	public string MyName { get { return myName; } } // публичный доступ к имени
	public float power = 20f; 

	void Awake () {
        netView = gameObject.AddComponent( typeof( NetworkView ) ) as NetworkView; // добавляем компонент NetworkView нашему игровому объекту
        netView.viewID = Network.AllocateViewID(); // присваиваем уникальный идентификатор в сети
        netView.observed = this; // указываем этот скрипт (компонент) для синхронизации
        netView.stateSynchronization = NetworkStateSynchronization.Unreliable; // нам подходит способ быстрой передачи с потерями, поскольку наше передвижение интерполируется
        lastSynchronizationTime = Time.time; // последнее время синхронизации
	}

	void FixedUpdate () {
        if ( netView.isMine ) { // если объект принадлежит нам, то мы им управляем, в противном случае делаем интерполяцию движения
            float inputX = Input.GetAxis( "Horizontal" );
            float inputY = Input.GetAxis( "Vertical" );
			if ( inputX != 0.0f ) {
                rigidbody.AddTorque( Vector3.forward * -inputX * power, ForceMode.Impulse );
            }
            if ( inputY != 0.0f ) {
                rigidbody.AddTorque( Vector3.right * inputY * power, ForceMode.Impulse );
            }
        } else {
			syncTime += Time.fixedDeltaTime;
            rigidbody.position = Vector3.Lerp( syncStartPosition, syncEndPosition, syncTime / syncDelay ); // интерполяция перемещения
            rigidbody.rotation = Quaternion.Lerp( syncStartRotation, syncEndRotation, syncTime / syncDelay ); // интерполяция поворота
		}
	}

    void OnSerializeNetworkView( BitStream stream, NetworkMessageInfo info ) {
        Vector3 syncPosition = Vector3.zero; // для синхронизации позиции
        Vector3 syncVelocity = Vector3.zero; // для синхронизации действующей силы
        Quaternion syncRotation = Quaternion.identity; // для синхронизации поворота

        if ( stream.isWriting ) { // если отправляем в сеть, то считываем данные объекта перед отправкой
            syncPosition = rigidbody.position;
            stream.Serialize( ref syncPosition );

            syncPosition = rigidbody.velocity;
            stream.Serialize( ref syncVelocity );

            syncRotation = rigidbody.rotation;
            stream.Serialize( ref syncRotation );
        } else { // в противном случае считываем из сети
            stream.Serialize( ref syncPosition );
            stream.Serialize( ref syncVelocity );
            stream.Serialize( ref syncRotation );

            syncTime = 0f; // сбрасываем время синхронизации
            syncDelay = Time.time - lastSynchronizationTime; // получаем дельту предыдущей синхронизации
            lastSynchronizationTime = Time.time; // записываем новое время последней синхронизации

            syncEndPosition = syncPosition + syncVelocity * syncDelay; // конечная точка, в которую движется объект
            syncStartPosition = rigidbody.position; // начальная точка равна текущей позиции

            syncEndRotation = syncRotation; // конечный поворот
            syncStartRotation = rigidbody.rotation; // начальный поворот
        }
    }
	
	void SetPlayerName( string name ) {
		myName = name; // задаем имя игрока
	}
}

Знаю, что синхронизация и управление должны находиться раздельно, но для примера я решил объединить их. Как вы заметили, здесь NetworkView создается во время инициализации скрипта. На мой взгляд, это более удобный способ для защиты от возможного «забыл добавить» (разумеется, если не написано RequireComponent( typeof( Rigidbody ))), а также уменьшает в инспекторе количество компонентов на объекте.
К примеру, у меня был случай: когда, на первый взгляд, все было сделано правильно, однако мой скрипт не делал интерполяцию, и все мои действия в синхронизации игнорировал. Так вот ошибкой оказалось то, что Observed был не моим скриптом, а трансформ объекта.

Итак, теперь у нас есть все необходимые скрипты для написания мини-игры.
Создаем пустой объект и назначаем ему скрипты MultiplayerMenu, ServerSide, ClientSide.
Создаем плоскость и немного опускаем.
Создаем префаб игрока (в моем примере это будут шары). Создаем объект «сфера», назначаем ему скрипт PlayerControls и добавляем в префаб. Префаб перетягиваем на ClientSide в поле Player Prefab.
На этом все, компилируем проект (не забывая в настройках игрока включить Run in background) и запускаем несколько раз. В одном из окон жмем сервер, на остальных – клиент, и смотрим на результат.

Ссылка на проект.
*В проекте могут быть логические ошибки, но на суть данной статьи они не влияют.

Всех благодарю за внимание!
Желаю успехов в создании многопользовательских игр!

Автор: gatools

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js