Хочу поделиться опытом с теми, кто хочет попробовать себя в написании сетевой игры, но не знает с чего начать. Так как информации по этой теме в интернете много, но полезную и актуальную было найти тяжело (а в русскоязычном сегменте и подавно), я решил собрать и структурировать то, что удалось найти.
Итак, для написания сетевой игры на Unity сейчас есть несколько вариантов:
UNet. Устаревшая сетевая технология. На данный момент deprecated и поддержка закончится в ближайшие пару лет. Но что же Unity предлагает взамен?
NetCode. Потенциально крутая технология, которая будет работать в связке с Entity Component System. Но очень уж медленно она развивается, за пару лет существования вышло 6 версий разной степени багованности, api постоянно меняется и делать что-то серьезное на нем пока рановато. Когда ее доделают – неизвестно. Я слежу за ней уже около года и особого прогресса не заметил.
Что тогда остается? Из бесплатных решений это:
MLAPI. Альтернатива UNet с широким спектром возможностей. Достойное решение, стоит к нему присмотреться.
Mirror. Доведенный до ума UNet, который потенциально может использоваться даже в MMO. Может работать как Клиент+Сервер, так и NoGUI-Сервер.
И платные решения (ознакомится с ними не удалось, напишите у кого был опыт как они):
Таблица преимуществ этих решений от Unity:
Мой выбор пал на Mirror, как на ближайший потомок UNet, использующий большинство принципов уже знакомого UNet. На примере простого проекта мы посмотрим основы Mirror, а именно:
Настройка окружения
NetworkMessage и spawn игрока в выбранной точке
Синхронизация переменных посредством SyncVar
Синхронизация переменных посредством SyncList
Spawn предмета и взаимодействие с предметом
1. Настройка окружения
Для статьи будем использовать Unity 2020.3.0f1 и Mirror 32.1.4. Добавляем Mirror себе через Asset Store, создаем проект, импортируем Mirror (Window -> Package Manager -> Packages -> My Assets -> Mirror -> Import).
Для начала нам нужно создать префаб игрока. Создаем пустой GameObject (назовем его Player), вешаем на него SpriteRenderer, задаем sprite Knob и масштабируем чтобы лучше его рассмотреть. Далее создаем скрипт Player.cs и вешаем его на тот же GameObject. Редактируем скрипт следующим образом:
using Mirror;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Player : NetworkBehaviour //даем системе понять, что это сетевой объект
{
void Update()
{
if (hasAuthority) //проверяем, есть ли у нас права изменять этот объект
{
float h = Input.GetAxis("Horizontal");
float v = Input.GetAxis("Vertical");
float speed = 5f * Time.deltaTime;
transform.Translate(new Vector2(h * speed, v * speed)); //делаем простейшее движение
}
}
}
Подробнее про NetworkBehaviour и NetworkIdentity
Компонент NetworkIdentity добавится автоматически при добавлении скрипта, наследуемого от NetworkBehaviour.
В одном GameObject (и всех его потомках) может быть только один NetworkIdentity.
NetworkIdentity позволяет отличить один сетевой объект от другого (для этого используем netId - его значение всегда будет уникальным).
Добавляем компонент NetworkTransform, чтобы положение нашего игрока синхронизировалось между всеми игроками. Ставим галочку ClientAuthority, чтобы изменения произведенные клиентом, считались валидными.
Подробнее про NetworkTransform
Компонент NetworkIdentity также добавится автоматически при добавлении NetworkTransform (если его еще не было).
Если вам нужно синхронизировать потомков, добавляйте NetworkTransformChild на тот же объект, где уже есть NetworkIdentity, и указывайте в Target тот transform, который нужно синхронизировать.
Делаем из нашего GameObject префаб. Получилось что-то такое:
Далее создаем скрипт NetMan.cs, создаем пустой GameObject (назовем его NetMan) и вешаем на него скрипт. Это будет наш скрипт, который отвечает за старт сервера и подключение игроков.
Пока просто наследуем класс от NetworkManager, на этом этапе этого будет достаточно.
using Mirror;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class NetMan : NetworkManager
{
}
У нас в инспекторе появятся настройки сервера и добавится компонент KcpTransport. Докидываем на тот же GameObject компонент NetworkManagerHUD (он создает необходимое для подключения GUI).
Остановимся подробнее на настройках:
Don’t Destroy On Load. Будет ли объект существовать между сценами?
Run In Background. Будет ли компонент продолжать работать когда окно программы неактивно?
Auto Start Server Build. Будет ли сервер стартовать автоматически, если была выбрана опция билда «Server Build»?
Show Debug Messages. По этой опции не удалось разобраться или найти какую-то информацию.
Server Tick Rate. Количество обновлений сервера в секунду.
Server Batching. Должен ли сервер сначала собрать текущую сетевую информацию и отправить ее в LateUpdate разом? Полезно для уменьшения нагрузки на CPU и сеть, но увеличивает задержку.
Server Batch Interval. Чем выше это значение, тем реже будет отправляться сетевая информация.
Теперь нам нужно указать префаб, который будет спавниться в качестве игрока. Перетаскиваем префаб Player в поле Player Prefab и после этого убираем его со сцены (оставляем только камеру и NetMan).
Первый этап готов. Выставляем выполнение в неполном экране (чтобы несколько экземпляров помещалось), делаем сборку, запускаем 2 экземпляра и проверяем. Один экземпляр стартуем как сервер, второй как клиент. На wasd двигаем своего персонажа, он успешно синхронизируется с другим экземпляром.
2. NetworkMessage и spawn игрока в выбранной точке
На примере спавна в выбранной точке мы научимся отправлять сообщения на сервер.
В настройках NetMan убираем галочку AutoCreatePlayer, дальше мы будем контролировать спавн игрока сами. Для этого мы изменим скрипт NetMan.cs. Начнем с создания struct с данными о позиции:
public struct PosMessage : NetworkMessage //наследуемся от интерфейса NetworkMessage, чтобы система поняла какие данные упаковывать
{
public Vector2 vector2; //нельзя использовать Property
}
Далее создадим метод непосредственно спавна, который будет выполняется только на сервере:
public void OnCreateCharacter(NetworkConnection conn, PosMessage message)
{
GameObject go = Instantiate(playerPrefab, message.vector2, Quaternion.identity); //локально на сервере создаем gameObject
NetworkServer.AddPlayerForConnection(conn, go); //присоеднияем gameObject к пулу сетевых объектов и отправляем информацию об этом остальным игрокам
}
Теперь перегрузим мтод OnStartServer (выполняется только на сервере) и добавим в него обработчик сетевого сообщения:
public override void OnStartServer()
{
base.OnStartServer();
NetworkServer.RegisterHandler<PosMessage>(OnCreateCharacter); //указываем, какой struct должен прийти на сервер, чтобы выполнился свапн
}
Создадим метод, который будет активировать спавн (и выполняться локально на клиенте):
bool playerSpawned;
public void ActivatePlayerSpawn()
{
Vector3 pos = Input.mousePosition;
pos.z = 10f;
pos = Camera.main.ScreenToWorldPoint(pos);
PosMessage m = new PosMessage() { vector2 = pos }; //создаем struct определенного типа, чтобы сервер понял к чему эти данные относятся
connection.Send(m); //отправка сообщения на сервер с координатами спавна
playerSpawned = true;
}
И напоследок зададим условия для активации спавна:
NetworkConnection connection;
bool playerConnected;
public override void OnClientConnect(NetworkConnection conn)
{
base.OnClientConnect(conn);
connection = conn;
playerConnected = true;
}
private void Update()
{
if (Input.GetKeyDown(KeyCode.Mouse0) && !playerSpawned && playerConnected)
{
ActivatePlayerSpawn();
}
}
В итоге получаем такой скрипт NetMan.cs:
using Mirror;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class NetMan : NetworkManager
{
bool playerSpawned;
NetworkConnection connection;
bool playerConnected;
public void OnCreateCharacter(NetworkConnection conn, PosMessage message)
{
GameObject go = Instantiate(playerPrefab, message.vector2, Quaternion.identity); //локально на сервере создаем gameObject
NetworkServer.AddPlayerForConnection(conn, go); //присоеднияем gameObject к пулу сетевых объектов и отправляем информацию об этом остальным игрокам
}
public override void OnStartServer()
{
base.OnStartServer();
NetworkServer.RegisterHandler<PosMessage>(OnCreateCharacter); //указываем, какой struct должен прийти на сервер, чтобы выполнился свапн
}
public void ActivatePlayerSpawn()
{
Vector3 pos = Input.mousePosition;
pos.z = 10f;
pos = Camera.main.ScreenToWorldPoint(pos);
PosMessage m = new PosMessage() { vector2 = pos }; //создаем struct определенного типа, чтобы сервер понял к чему эти данные относятся
connection.Send(m); //отправка сообщения на сервер с координатами спавна
playerSpawned = true;
}
public override void OnClientConnect(NetworkConnection conn)
{
base.OnClientConnect(conn);
connection = conn;
playerConnected = true;
}
private void Update()
{
if (Input.GetKeyDown(KeyCode.Mouse0) && !playerSpawned && playerConnected)
{
ActivatePlayerSpawn();
}
}
}
public struct PosMessage : NetworkMessage //наследуемся от интерфейса NetworkMessage, чтобы система поняла какие данные упаковывать
{
public Vector2 vector2; //нельзя использовать Property
}
Второй этап готов. Делаем сборку, проверяем. После подключения нужно кликнуть левой кнопкой мыши в точку, где игрок хочет засвапниться.
3. Синхронизация переменных посредством SyncVar
Переходим к очень интересной фиче – SyncVar. Она позволяет избежать ручной синхронизации данных. Главное правило – меняем переменную только на сервере и не используем ее как данные (только как временное хранилище для данных, которые нам нужно обработать).
Для начала подготовим объекты, которые мы будем использовать для наглядной синхронизации. Например, здоровье в виде красных кружков. Открываем редактирование префаба Player и добавляем ему несколько объектов, представляющих собой жизнь (Knob + красный цвет). Располагаем их так, чтобы было хорошо видно.
Редактируем скрипт Player.cs, добавляем переменные:
public int Health;
public GameObject[] HealthGos;
Сохраняем, закидываем объекты-жизни в переменную HealthGos и выставляем такое же количество в переменной Health.
Добавляем в Update обновление объектов-жизней в соответствии с количеством жизней:
void Update()
{
...
for (int i = 0; i < HealthGos.Length; i++)
{
HealthGos[i].SetActive(!(Health - 1 < i));
}
}
И переходим к методу на клиенте, который будет выставлять Health в соответствии с синхронизированным значением:
[SyncVar(hook = nameof(SyncHealth))] //задаем метод, который будет выполняться при синхронизации переменной
int _SyncHealth;
void SyncHealth(int oldValue, int newValue) //обязательно делаем два значения - старое и новое.
{
Health = newValue;
}
Теперь нам нужно сделать метод, который будет менять переменную _SyncHealth. Этот метод будет выполняться только на сервере.
[Server] //обозначаем, что этот метод будет вызываться и выполняться только на сервере
public void ChangeHealthValue(int newValue)
{
_SyncHealth = newValue;
}
Далее переходим к методу, который также будет выполняться на сервере, но клиент сможет запросить его выполнение:
[Command] //обозначаем, что этот метод должен будет выполняться на сервере по запросу клиента
public void CmdChangeHealth(int newValue) //обязательно ставим Cmd в начале названия метода
{
ChangeHealthValue(newValue); //переходим к непосредственному изменению переменной
}
Подробнее про Command и Rpc
Command используется для того, чтобы клиенты могли попросить сервер выполнить заданную команду.
Rpc используется для того, чтобы сервер мог попросить клиентов выполнить заданную команду.
Command можно вызывать на сервере+клиенте, но Rpc нельзя вызывать на клиенте.
Передавать в Rpc и Command можно только ограниченный набор типов.
Вызов Rpc в режиме сервер+клиент также выполнится на нем самом.
Пример Rpc:
[ClientRpc] //обозначаем, что этот метод будет выполняться на клиенте по запросу сервера
public void RpcTest() //обязательно ставим Rpc в начале названия метода
{
Debug.Log("Сервер попросил меня это написать");
}
Все готово для синхронизации, зададим условия изменения жизней. На этом этапе сделаем простую схему – каждый игрок может только уменьшить свои жизни. Для этого дополним Update:
void Update()
{
if (hasAuthority) //проверяем, есть ли у нас права изменять этот объект
{
...
if (Input.GetKeyDown(KeyCode.H)) //отнимаем у себя жизнь по нажатию клавиши H
{
if (isServer) //если мы являемся сервером, то переходим к непосредственному изменению переменной
ChangeHealthValue(Health - 1);
else
CmdChangeHealth(Health - 1); //в противном случае делаем на сервер запрос об изменении переменной
}
}
...
}
Этап завершен. Теперь у игроков всегда будет актуальное количество жизней, даже у тех, кто присоединяется позднее (после изменения количества жизней у других игроков).
4. Синхронизация переменных посредством SyncList
Синхронизировать одну переменную это конечно хорошо, но для серьезных проектов нам понадобится инструмент посерьезнее. SyncList позволяет синхронизировать массивы данных. Разберемся с ним на примере сохранения пройденного пути по нажатию кнопки (просто для наглядности). Редактируем скрипт Player.cs по аналогии с SyncVar.
Изменение массива на сервере:
SyncList<Vector3> _SyncVector3Vars = new SyncList<Vector3>(); //В случае SyncList не нужно ставить SyncVar и задавать метод, это делается иначе
[Server]
void ChangeVector3Vars(Vector3 newValue)
{
_SyncVector3Vars.Add(newValue);
}
Команда для запроса с клиента на сервер:
[Command]
public void CmdChangeVector3Vars(Vector3 newValue)
{
ChangeVector3Vars(newValue);
}
И обработчик события изменения массива на клиенте:
public List<Vector3> Vector3Vars;
void SyncVector3Vars(SyncList<Vector3>.Operation op, int index, Vector3 oldItem, Vector3 newItem)
{
switch (op)
{
case SyncList<Vector3>.Operation.OP_ADD:
{
Vector3Vars.Add(newItem);
break;
}
case SyncList<Vector3>.Operation.OP_CLEAR:
{
break;
}
case SyncList<Vector3>.Operation.OP_INSERT:
{
break;
}
case SyncList<Vector3>.Operation.OP_REMOVEAT:
{
break;
}
case SyncList<Vector3>.Operation.OP_SET:
{
break;
}
}
}
Теперь перегрузим метод старта клиента:
public override void OnStartClient()
{
base.OnStartClient();
_SyncVector3Vars.Callback += SyncVector3Vars; //вместо hook, для SyncList используем подписку на Callback
Vector3Vars = new List<Vector3>(_SyncVector3Vars.Count); //так как Callback действует только на изменение массива,
for (int i = 0; i < _SyncVector3Vars.Count; i++) //а у нас на момент подключения уже могут быть какие-то данные в массиве, нам нужно эти данные внести в локальный массив
{
Vector3Vars.Add(_SyncVector3Vars[i]);
}
}
Синхронизация готова, но нам нужно задать условия изменения массива и визуализировать данные. Создадим пустой GameObject + SpriteRenderer + Knob + меняем цвет. Сохраняем как префаб Point.
Добавим компонент LineRenderer на префаб Player, выставим ему ноль позиций и немного уменьшим ширину. Отредактируем скрипт Player.cs:
public GameObject PointPrefab; //сюда вешаем ранее созданные префаб Point
public LineRenderer LineRenderer; //сюда кидаем наш же компонент
int pointsCount;
void Update()
{
if (hasAuthority) //проверяем, есть ли у нас права изменять этот объект
{
...
if (Input.GetKeyDown(KeyCode.P))
{
if (isServer)
ChangeVector3Vars(transform.position);
else
CmdChangeVector3Vars(transform.position);
}
}
...
for (int i = pointsCount; i < Vector3Vars.Count; i++)
{
Instantiate(PointPrefab, Vector3Vars[i], Quaternion.identity);
pointsCount++;
LineRenderer.positionCount = Vector3Vars.Count;
LineRenderer.SetPositions(Vector3Vars.ToArray());
}
}
Как будут выглядеть Player и Point
Скрипт Player.cs
using Mirror;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Player : NetworkBehaviour //даем системе понять, что это сетевой объект
{
[SyncVar(hook = nameof(SyncHealth))] //задаем метод, который будет выполняться при синхронизации переменной
int _SyncHealth;
public int Health;
public GameObject[] HealthGos;
SyncList<Vector3> _SyncVector3Vars = new SyncList<Vector3>(); //В случае SyncList не нужно ставить SyncVar и задавать метод, это делается иначе
public List<Vector3> Vector3Vars;
public GameObject PointPrefab; //сюда вешаем ранее созданные префаб Point
public LineRenderer LineRenderer; //сюда кидаем наш же компонент
int pointsCount;
void Update()
{
if (hasAuthority) //проверяем, есть ли у нас права изменять этот объект
{
float h = Input.GetAxis("Horizontal");
float v = Input.GetAxis("Vertical");
float speed = 5f * Time.deltaTime;
transform.Translate(new Vector2(h * speed, v * speed)); //делаем простейшее движение
if (Input.GetKeyDown(KeyCode.H)) //отнимаем у себя жизнь по нажатию клавиши H
{
if (isServer) //если мы являемся сервером, то переходим к непосредственному изменению переменной
ChangeHealthValue(Health - 1);
else
CmdChangeHealth(Health - 1); //в противном случае делаем на сервер запрос об изменении переменной
}
if (Input.GetKeyDown(KeyCode.P))
{
if (isServer)
ChangeVector3Vars(transform.position);
else
CmdChangeVector3Vars(transform.position);
}
}
for (int i = 0; i < HealthGos.Length; i++)
{
HealthGos[i].SetActive(!(Health - 1 < i));
}
for (int i = pointsCount; i < Vector3Vars.Count; i++)
{
Instantiate(PointPrefab, Vector3Vars[i], Quaternion.identity);
pointsCount++;
LineRenderer.positionCount = Vector3Vars.Count;
LineRenderer.SetPositions(Vector3Vars.ToArray());
}
}
void SyncHealth(int oldValue, int newValue) //обязательно делаем два значения - старое и новое.
{
Health = newValue;
}
[Server] //обозначаем, что этот метод будет вызываться и выполняться только на сервере
public void ChangeHealthValue(int newValue)
{
_SyncHealth = newValue;
}
[Command] //обозначаем, что этот метод должен будет выполняться на сервере по запросу клиента
public void CmdChangeHealth(int newValue) //обязательно ставим Cmd в начале названия метода
{
ChangeHealthValue(newValue); //переходим к непосредственному изменению переменной
}
[Server]
void ChangeVector3Vars(Vector3 newValue)
{
_SyncVector3Vars.Add(newValue);
}
[Command]
public void CmdChangeVector3Vars(Vector3 newValue)
{
ChangeVector3Vars(newValue);
}
void SyncVector3Vars(SyncList<Vector3>.Operation op, int index, Vector3 oldItem, Vector3 newItem)
{
switch (op)
{
case SyncList<Vector3>.Operation.OP_ADD:
{
Vector3Vars.Add(newItem);
break;
}
case SyncList<Vector3>.Operation.OP_CLEAR:
{
break;
}
case SyncList<Vector3>.Operation.OP_INSERT:
{
break;
}
case SyncList<Vector3>.Operation.OP_REMOVEAT:
{
break;
}
case SyncList<Vector3>.Operation.OP_SET:
{
break;
}
}
}
public override void OnStartClient()
{
base.OnStartClient();
_SyncVector3Vars.Callback += SyncVector3Vars; //вместо hook для SyncList используем подписку на Callback
Vector3Vars = new List<Vector3>(_SyncVector3Vars.Count); //так как Callback действует только на изменение массива,
for (int i = 0; i < _SyncVector3Vars.Count; i++) //а у нас на момент подключения уже могут быть какие-то данные в массиве, нам нужно эти данные внести в локальный массив
{
Vector3Vars.Add(_SyncVector3Vars[i]);
}
}
}
Этап завершен, посмотрим на результат. Во время выполнения игрок может нажать клавишу P и его позиция отправится в массив для синхронизации всем игрокам. Также точки соединяться линией, чтобы маршрут был виден наглядно.
5. Spawn предмета и взаимодействие с ним
На последнем этапе мы посмотрим как спавнить предметы и взаимодействовать с ними. Добавим нашему игроку возможность стрелять пулями.
Создадим новый скрипт Bullet.cs:
using Mirror;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Bullet : NetworkBehaviour
{
uint owner;
bool inited;
Vector3 target;
[Server]
public void Init(uint owner, Vector3 target)
{
this.owner = owner; //кто сделал выстрел
this.target = target; //куда должна лететь пуля
inited = true;
}
void Update()
{
if (inited && isServer)
{
transform.Translate((target - transform.position).normalized * 0.04f);
foreach (var item in Physics2D.OverlapCircleAll(transform.position, 0.5f))
{
Player player = item.GetComponent<Player>();
if (player)
{
if (player.netId != owner)
{
player.ChangeHealthValue(player.Health - 1); //отнимаем одну жизнь по аналогии с примером SyncVar
NetworkServer.Destroy(gameObject); //уничтожаем пулю
}
}
}
if (Vector3.Distance(transform.position, target) < 0.1f) //пуля достигла конечной точки
{
NetworkServer.Destroy(gameObject); //значит ее можно уничтожить
}
}
}
}
Также создадим пустой GameObject + SpriteRenderer + Knob + меняем цвет. Вешаем на него скрипт Bullet.cs. Добавляем компонент NetworkTransform. Сохраняем как префаб Bullet.
В скрипт Player.cs добавляем спавн пули на сервере:
[Server]
public void SpawnBullet(uint owner, Vector3 target)
{
GameObject bulletGo = Instantiate(BulletPrefab, transform.position, Quaternion.identity); //Создаем локальный объект пули на сервере
NetworkServer.Spawn(bulletGo); //отправляем информацию о сетевом объекте всем игрокам.
bulletGo.GetComponent<Bullet>().Init(owner, target); //инициализируем поведение пули
}
И запрос на свапн со стороны клиента:
[Command]
public void CmdSpawnBullet(uint owner, Vector3 target)
{
SpawnBullet(owner, target);
}
Выставляем условие появления пули:
void Update()
{
if (hasAuthority) //проверяем, есть ли у нас права изменять этот объект
{
...
if (Input.GetKeyDown(KeyCode.Mouse1))
{
Vector3 pos = Input.mousePosition;
pos.z = 10f;
pos = Camera.main.ScreenToWorldPoint(pos);
if (isServer)
SpawnBullet(netId, pos);
else
CmdSpawnBullet(netId, pos);
}
}
...
}
Добавим еще уничтожение игрока, если жизни закончились:
[Server] //обозначаем, что этот метод будет вызываться и выполняться только на сервере
public void ChangeHealthValue(int newValue)
{
_SyncHealth = newValue;
if (_SyncHealth <= 0)
{
NetworkServer.Destroy(gameObject);
}
}
В настройках NetMan выставляем префаб Bullet как доступный для спавна:
Не забываем выставить префаб Bullet в переменную BulletPrefab префаба Player. Напоследок добавляем на префаб Player компонент CircleCollider2D и ставим галочку IsTrigger, чтобы пуля могла отловить попадание.
Последний этап завершен. Проверяем. По нажатию правой кнопки мыши из игрока вылетает пуля и летит туда, где стоял курсор. Если по пути пуля встречает другого игрока – он теряет одну жизнь. Все пули синхронизированы, даже если игрок подключился после их спавна.
Скрипт Player.cs
using Mirror;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Player : NetworkBehaviour //даем системе понять, что это сетевой объект
{
[SyncVar(hook = nameof(SyncHealth))] //задаем метод, который будет выполняться при синхронизации переменной
int _SyncHealth;
public int Health;
public GameObject[] HealthGos;
SyncList<Vector3> _SyncVector3Vars = new SyncList<Vector3>(); //В случае SyncList не нужно ставить SyncVar и задавать метод, это делается иначе
public List<Vector3> Vector3Vars;
public GameObject PointPrefab; //сюда вешаем ранее созданные префаб Point
public LineRenderer LineRenderer; //сюда кидаем наш же компонент
int pointsCount;
public GameObject BulletPrefab; //сюда вешаем префаб пули
void Update()
{
if (hasAuthority) //проверяем, есть ли у нас права изменять этот объект
{
float h = Input.GetAxis("Horizontal");
float v = Input.GetAxis("Vertical");
float speed = 5f * Time.deltaTime;
transform.Translate(new Vector2(h * speed, v * speed)); //делаем простейшее движение
if (Input.GetKeyDown(KeyCode.H)) //отнимаем у себя жизнь по нажатию клавиши H
{
if (isServer) //если мы являемся сервером, то переходим к непосредственному изменению переменной
ChangeHealthValue(Health - 1);
else
CmdChangeHealth(Health - 1); //в противном случае делаем на сервер запрос об изменении переменной
}
if (Input.GetKeyDown(KeyCode.P))
{
if (isServer)
ChangeVector3Vars(transform.position);
else
CmdChangeVector3Vars(transform.position);
}
if (Input.GetKeyDown(KeyCode.Mouse1))
{
Vector3 pos = Input.mousePosition;
pos.z = 10f;
pos = Camera.main.ScreenToWorldPoint(pos);
if (isServer)
SpawnBullet(netId, pos);
else
CmdSpawnBullet(netId, pos);
}
}
for (int i = 0; i < HealthGos.Length; i++)
{
HealthGos[i].SetActive(!(Health - 1 < i));
}
for (int i = pointsCount; i < Vector3Vars.Count; i++)
{
Instantiate(PointPrefab, Vector3Vars[i], Quaternion.identity);
pointsCount++;
LineRenderer.positionCount = Vector3Vars.Count;
LineRenderer.SetPositions(Vector3Vars.ToArray());
}
}
void SyncHealth(int oldValue, int newValue) //обязательно делаем два значения - старое и новое.
{
Health = newValue;
}
[Server] //обозначаем, что этот метод будет вызываться и выполняться только на сервере
public void ChangeHealthValue(int newValue)
{
_SyncHealth = newValue;
if (_SyncHealth <= 0)
{
NetworkServer.Destroy(gameObject);
}
}
[Command] //обозначаем, что этот метод должен будет выполняться на сервере по запросу клиента
public void CmdChangeHealth(int newValue) //обязательно ставим Cmd в начале названия метода
{
ChangeHealthValue(newValue); //переходим к непосредственному изменению переменной
}
[Server]
void ChangeVector3Vars(Vector3 newValue)
{
_SyncVector3Vars.Add(newValue);
}
[Command]
public void CmdChangeVector3Vars(Vector3 newValue)
{
ChangeVector3Vars(newValue);
}
void SyncVector3Vars(SyncList<Vector3>.Operation op, int index, Vector3 oldItem, Vector3 newItem)
{
switch (op)
{
case SyncList<Vector3>.Operation.OP_ADD:
{
Vector3Vars.Add(newItem);
break;
}
case SyncList<Vector3>.Operation.OP_CLEAR:
{
break;
}
case SyncList<Vector3>.Operation.OP_INSERT:
{
break;
}
case SyncList<Vector3>.Operation.OP_REMOVEAT:
{
break;
}
case SyncList<Vector3>.Operation.OP_SET:
{
break;
}
}
}
public override void OnStartClient()
{
base.OnStartClient();
_SyncVector3Vars.Callback += SyncVector3Vars; //вместо hook для SyncList используем подписку на Callback
Vector3Vars = new List<Vector3>(_SyncVector3Vars.Count); //так как Callback действует только на изменение массива,
for (int i = 0; i < _SyncVector3Vars.Count; i++) //а у нас на момент подключения уже могут быть какие-то данные в массиве, нам нужно эти данные внести в локальный массив
{
Vector3Vars.Add(_SyncVector3Vars[i]);
}
}
[Server]
public void SpawnBullet(uint owner, Vector3 target)
{
GameObject bulletGo = Instantiate(BulletPrefab, transform.position, Quaternion.identity); //Создаем локальный объект пули на сервере
NetworkServer.Spawn(bulletGo); //отправляем информацию о сетевом объекте всем игрокам.
bulletGo.GetComponent<Bullet>().Init(owner, target); //инифиализируем поведение пули
}
[Command]
public void CmdSpawnBullet(uint owner, Vector3 target)
{
SpawnBullet(owner, target);
}
}
Заключение
Надеюсь эти примеры помогут разобраться с азами работы с сетью в Unity. Знатоков этой темы призываю к обсуждению недочетов (про производительность и GC сейчас речь не идет). Полный проект можно скачать на гитхабе по этой ссылке.
Nehc
Поставил плюс, добавил в закладки, но…
Есть одна проблема.
Обычно все мануалы следуют какой-то стандартной схеме: есть префабы игроков, которые спаунятся при присоединении, есть условные bullet, которые тоже спаунятся… Дальше Health — и опа — у нас очередной туториал фактически повторяющий официальную документацию. ) А вот стоит шагнуть чуть в сторону…
Допустим — шашки. ;) И желательно без спауна, т.е. расставленные заранее. Или карточная игра — где колода общая. Или боже упаси — игра, где есть свои объекты, есть противника, а есть тупо общие! И вот тут преодолеть дебри Authority — квест еще тот!
Splendidus Автор
Все верно, есть еще масса нераскрытых тем (по authority можно делать отдельный пост). Я лишь постарался собрать основы в одном месте с актуальным кодом. Именно с актуальным, так как лично столкнулся с болью гугления по темам пятилетней давности, код в которых уже не работает. За плюс спасибо.