Pull to refresh

Основы Unity + Mirror

Reading time16 min
Views46K

UPD. Актуализировал на момент 26.11.2022.

Хочу поделиться опытом с теми, кто хочет попробовать себя в написании сетевой игры, но не знает с чего начать. Так как информации по этой теме в интернете много, но полезную и актуальную было найти тяжело (а в русскоязычном сегменте и подавно), я решил собрать и структурировать то, что удалось найти.

Итак, для написания сетевой игры на Unity сейчас есть несколько вариантов:

  • UNet. Устаревшая сетевая технология. На данный момент deprecated и поддержка закончится в ближайшие пару лет. Но что же Unity предлагает взамен?

  • NetCode. Потенциально крутая технология, которая будет работать в связке с Entity Component System. Но очень уж медленно она развивается, за пару лет существования вышло 6 версий разной степени багованности, api постоянно меняется и делать что-то серьезное на нем пока рановато. Когда ее доделают – неизвестно. Я слежу за ней уже около года и особого прогресса не заметил.

Что тогда остается? Из бесплатных решений это:

  • MLAPI. Альтернатива UNet с широким спектром возможностей. Достойное решение, стоит к нему присмотреться.

  • Mirror. Доведенный до ума UNet, который потенциально может использоваться даже в MMO. Может работать как Клиент+Сервер, так и NoGUI-Сервер.

И платные решения (ознакомится с ними не удалось, напишите у кого был опыт как они):

Таблица преимуществ этих решений от Unity:

Мой выбор пал на Mirror, как на ближайший потомок UNet, использующий большинство принципов уже знакомого UNet. На примере простого проекта мы посмотрим основы Mirror, а именно:

  1. Настройка окружения

  2. NetworkMessage и spawn игрока в выбранной точке

  3. Синхронизация переменных посредством SyncVar

  4. Синхронизация переменных посредством SyncList

  5. Spawn предмета и взаимодействие с предметом


1. Настройка окружения

Для статьи будем использовать Unity 2021.3.14f1 и Mirror 2022.10.0. Добавляем 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 (isOwned) //проверяем, есть ли у нас права изменять этот объект
        {
            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, чтобы изменения произведенные клиентом, считались валидными.

UPD. С версии 2022.10.0 это галочка считается устаревшей, вместо нее выбираем способ синхронизации:

SyncSettings -> Sync Direction -> ClientToServer

Подробнее про 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(NetworkConnectionToClient 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 определенного типа, чтобы сервер понял к чему эти данные относятся
  NetworkClient.Send(m); //отправка сообщения на сервер с координатами спавна
  playerSpawned = true;
}

И напоследок зададим условия для активации спавна:

bool playerConnected;

public override void OnClientConnect()
{
  base.OnClientConnect(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;
    bool playerConnected;

    public void OnCreateCharacter(NetworkConnectionToClient 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 определенного типа, чтобы сервер понял к чему эти данные относятся
        NetworkClient.Send(m); //отправка сообщения на сервер с координатами спавна
        playerSpawned = true;
    }

    public override void OnClientConnect()
    {
        base.OnClientConnect(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 (isOwned) //проверяем, есть ли у нас права изменять этот объект
  {
    ...
    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 (isOwned) //проверяем, есть ли у нас права изменять этот объект
  {
    ...
    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 (isOwned) //проверяем, есть ли у нас права изменять этот объект
        {
            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 (isOwned) //проверяем, есть ли у нас права изменять этот объект
  {
    ...
      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 (isOwned) //проверяем, есть ли у нас права изменять этот объект
        {
            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 сейчас речь не идет). Полный проект можно скачать на гитхабе по этой ссылке.

Tags:
Hubs:
+17
Comments2

Articles