Duck Game: Гайд по неткоду для мододелов

Понимание того, как работает неткод сделает ваши оружия/предметы совместимыми с мультиплеером без необходимости проводить кучу тестов, и если у вас нет друзей или двух компьютеров, это может сэкономить часы ненужной отладки и безвыходных ситуаций.
P.s данный гайд является переводом. Оригинал: https://steamcommunity.com/sharedfiles/filedetails/?id=1394086869

 

Соединения и прочее

Для начала я хочу вкратце объяснить, как работает неткод (сетевой код), а в новых версиях он довольно сложен и использует «призраков» (ghosts) для синхронизации данных, не требуя интенсивного использования процессора. Но для нас это не имеет большого значения, поскольку он является частью бэкенда («внутреннего неткода»), который не влияет на ваш код.

Более релевантные части неткода.

Неткод состоит из соединений с другими утками, и вы можете отправлять NetMessage’и (сетевыми сообщениями) либо конкретным соединениям, либо всем соединениям (обычно просто передавая null в качестве соединения).

Каждая вещь на уровне принадлежит некоторому соединению, и владелец этого соединения будет отправлять свойства о конкретной вещи всем остальным; поэтому хост/сервер не контролирует все, что я считаю довольно распространенным заблуждением. В начале уровня хост контролирует все, кроме других уток.

Я объясню это подробнее в тех частях, где вы на самом деле их используете.

Statebinding’и

StateBinding’и (что-то вроде связи состояния) сообщают бэкенду, какие переменные синхронизировать с другими соединениями.

public int myNumber = 25;
public StateBinding _number = new StaeBinding("myNumber",-1,false,false);
//это гарантирует автоматическую синхронизацию int myNumber.

Есть несколько перегрузок для StateBinding, большинство из которых никогда не будут использоваться.

Основной, который вы, скорее всего, использовать практически для всего (и который использовался в первом примере):

public StateBinding(string field, int bits = -1, bool rot = false, bool vel = false)

 

Значение bits — это размер поля, то есть Int32 состоит из 32 бит. Если значение равно -1, размер будет получен автоматически. А экономия пары наносекунд каждый матч не стоит возможного риска ошибиться в значении. Так что оставляйте его на -1. Вы можете нагляднее увидеть, как это работает в классе BitBuffer, используя какой-нибудь декомпилятор (например, DnsPy).

Булевы значения rot и vel используются только в том случае, если вы имеете дело с вращениями и скоростями, и, как я понял, они используются только при вычислении физики. Не меняйте их значения на true, если вы не знаете, что делаете.

Вот другие перегрузки, которые вы можете использовать (и что они делают):

//изменяет значение постепенно вместо мгновенных скачков, работает только с числами с плавающей запятой.
public StateBinding(bool doLerp, string field, int bits = -1, bool rot = false, bool vel = false);
//наиболее специфичный конструктор statebind, работает как обычный + вы можете сделать его "плавным" и установить его важность (низкий, средний или высокий уровень). Использование данного конструктора может замедлить работу других вещей, поэтому вам не следует его использовать.
public StateBinding(GhostPriority p, string field, int bits = -1, bool rot = false, bool vel = false, bool doLerp = false)

Типы, к которым можно применить statebind:]
  • string
  • float
  • double
  • byte
  • sbyte
  • bool
  • short
  • ushort
  • int
  • uint
  • long
  • ulong
  • char
  • Vec2
  • BitBuffer
  • NetIndex16
  • NetIndex2
  • NetIndex4dex4
  • NetIndex8
  • Всё, что наследует или является Thing

StateBind также будет работать со свойствами. Одним из примеров является тот, который используется в MegaLaser:

public StateBinding _frameBinding = new StateBinding("spriteFrame", -1, false, false);
public byte spriteFrame {
      get {
        if (this._chargeAnim == null)
          return (byte) 0;
        return (byte) this._chargeAnim._frame;
      }

      set {
        if (this._chargeAnim == null)
          return;
        this._chargeAnim._frame = (int) value;
      }
    }
NetSoundBinding

NetSoundEffect’ы похожи на обычные звуки, и они также могут запускать функции при воспроизведении звука. Так же их высота может зависеть от другого значения. Но самое главное, при использовании с NetSoundBinding они будут синхронизироваться онлайн.

NetSoundEffect’ы и NetSoundBinding’и используются, например, когда утка крякает или когда вы бьете по барабану.

Вы можете использовать NetSoundEffect двумя способами:

public StateBinding _netBounceSoundBinding = (StateBinding) new NetSoundBinding("_bounceSound "); //Statebinding для _bounceSound
public StateBinding _netYellSoundBinding = (StateBinding) new NetSoundBinding("_netYellSound "); //StateBinding для _netYellSound

public NetSoundEffect _bounceSound = new NetSoundEffect(); //Пустой SoundEffect, который будет привязан к функции.

public NetSoundEffect _netYellSound = new NetSoundEffect(new string[3]
    {
      "quackYell01",
      "quackYell02",
      "quackYell03"
    }); //Создание нового NetSoundEffect, который, будучи единожды воспроизведён, запустит три новых звука, и его синхронизация, чтобы остальные игроки услышали новый звук.
public override void Initialize()
{
    _bounceSound.function = new NetSoundEffect.Function(Bounce);
}

void Bounce()
{
    SFX.Play(GetPath("myBounceSound.wav"));
    shake = 5f;
}

Убедитесь, что тип переменной — StateBinding, а не NetSoundBinding

StateBinding’и (продолжение)

public StateFlagBinding(params string[] fields)

StateFlagBinding очень полезен, когда вы имеете дело с несколькими логическими значениями, например, если что-то открыто, закрыто или заблокировано.

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

Параметры должны быть bool для использования, и затем их значения будут сжаты в один ushort. Использование ushort означает, что у вас не может быть более 16 логических полей.

public StateBinding _laserStateBinding = (StateBinding) new StateFlagBinding(new string[3]
{
"_charging",
"_fired",
"doBlast"
});
public bool doBlast;
public bool _fired;
public bool _charging;

Пример взят из HugeLaser. На самом деле, поля _charging и_fired в нём не используются, и он зависит от кадра спрайта и индекса анимации, которые, в свою очередь, синхронизированы. И это лучший способ вызова, так как иначе была бы задержка между всеми клиентами. Более того, в Writing Proper Code это работает со StateBindigs (parts).

Типы StateBindigs, которые вам не нужно знать, так как они используются только для оптимизации:

DataBinding(string field) — Работает более эффективно с BitBuffers, и только с ними.
CompressedVec2Binding — Уменьшает размер Vec2. Все конструкторы поступают с максимальными значениями.
CompressedFloatBinding — Уменьшает размер float. Все конструкторы поступают с максимальными значениями.
InterpolatedVec2Binding[/b] — Расширение CompressedFloatBinding с наивысшим приоритетом.

[h]Как писать правильный код, который работает с StateBindinds.[/h]
This is especially important if you’re dealing with stuff like timers or values that change rapidly. Basically what you have to do is write your stuff as if they could randomly skip a bunch of numbers and still work in the end.

Это особенно важно, если вы имеете дело с таймерами или значениями, которые быстро меняются. По сути вам нужно написать код так, как-будто он может случайно пропускать несколько чисел и всё ещё работать

Вот пример: У вас есть float таймер, который уменьшает своё значение каждый фрейм, и как только он достигает нуля, что-то происходит.
Вот, как делать не стоит:

timer—;
if(timer == 0) doStuff();

Это отлично работает в локальной игре; но в онлайне каждый фрейм может отправляться только ограниченное количество данных, и с большой вероятностью значение таймера будет уменьшаться каждые три или больше фрейма. В таком случае, таймер будет уменьшаться как-то так(полагая, что он начинается с 10):
10 -> 10 -> 10 -> 7 -> 7 -> 5 -> 5 -> 5 -> 2 -> 2 -> 2 -> -1 -> -1 -> -3……
Как вы видите, 0 был пропущен и doStuff() никогда не запустился.

Вот, как это исправить:

timer—;
if(timer <= 0) doStuff();

Легко, не так ли? И это действительно заработает!
Иммено из-за этого происходил баг с бесконечной загрузкой интернет-карты. Ландон (разработчик) использовал ==, и когда необходимое число пропускалось из-за кривых карт, оно уходило дальше в бесконечность. К счастью, это было пофикшено в бете.

Хотя, это всего лишь половина пазла, так как не менее важно выбрать правильные значения для синхронизации. Кроме полей _charging и_fired, HugeLaser — это отличный пример того, как должны выглядеть таймеры.

Если кратко, то вот, что там происходит: 2 StateBinding для анимаций, использующие индекс анимации и кадр спрайта. Это может быть использовано для синхронизации анимаций.

public StateFlagBinding(params string[] fields)

StateFlagBinding очень полезен, когда вы имеете дело с несколькими логическими значениями, например, если что-то открыто, закрыто или заблокировано.

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

Параметры должны быть bool для использования, и затем их значения будут сжаты в один ushort. Использование ushort означает, что у вас не может быть более 16 логических полей.

public StateBinding _laserStateBinding = (StateBinding) new StateFlagBinding(new string[3]
{
"_charging",
"_fired",
"doBlast"
});
public bool doBlast;
public bool _fired;
public bool _charging;

Пример взят из HugeLaser. На самом деле, поля _charging и_fired в нём не используются, и он зависит от кадра спрайта и индекса анимации, которые, в свою очередь, синхронизированы. И это лучший способ вызова, так как иначе была бы задержка между всеми клиентами. Более того, в Writing Proper Code это работает со StateBindigs (parts).

Типы StateBindigs, которые вам не нужно знать, так как они используются только для оптимизации:

DataBinding(string field) — Работает более эффективно с BitBuffers, и только с ними.
CompressedVec2Binding — Уменьшает размер Vec2. Все конструкторы поступают с максимальными значениями.
CompressedFloatBinding — Уменьшает размер float. Все конструкторы поступают с максимальными значениями.
InterpolatedVec2Binding[/b] — Расширение CompressedFloatBinding с наивысшим приоритетом.

[h]Как писать правильный код, который работает с StateBindinds.[/h]
This is especially important if you’re dealing with stuff like timers or values that change rapidly. Basically what you have to do is write your stuff as if they could randomly skip a bunch of numbers and still work in the end.

Это особенно важно, если вы имеете дело с таймерами или значениями, которые быстро меняются. По сути вам нужно написать код так, как-будто он может случайно пропускать несколько чисел и всё ещё работать

Вот пример: У вас есть float таймер, который уменьшает своё значение каждый фрейм, и как только он достигает нуля, что-то происходит.
Вот, как делать не стоит:

timer—;
if(timer == 0) doStuff();

Это отлично работает в локальной игре; но в онлайне каждый фрейм может отправляться только ограниченное количество данных, и с большой вероятностью значение таймера будет уменьшаться каждые три или больше фрейма. В таком случае, таймер будет уменьшаться как-то так(полагая, что он начинается с 10):
10 -> 10 -> 10 -> 7 -> 7 -> 5 -> 5 -> 5 -> 2 -> 2 -> 2 -> -1 -> -1 -> -3……
Как вы видите, 0 был пропущен и doStuff() никогда не запустился.

Вот, как это исправить:

timer—;
if(timer <= 0) doStuff();

Легко, не так ли? И это действительно заработает!
Иммено из-за этого происходил баг с бесконечной загрузкой интернет-карты. Ландон (разработчик) использовал ==, и когда необходимое число пропускалось из-за кривых карт, оно уходило дальше в бесконечность. К счастью, это было пофикшено в бете.Хотя, это всего лишь половина пазла, так как не менее важно выбрать правильные значения для синхронизации. Кроме полей _charging и_fired, HugeLaser — это отличный пример того, как должны выглядеть таймеры.Если кратко, то вот, что там происходит: 2 StateBinding для анимаций, использующие индекс анимации и кадр спрайта. Это может быть использовано для синхронизации анимаций.И ещё есть поле bool doBlast, которое становиться истинным, когда владелец выстрелил из оружия. И, если doBlast истинно (потому что это StateBinding) и вы не являетесь владельцем, происходит выстрел.
Далее идут самые важные части, которые не допускают двойного выстрела и мнговенного отключения doBlast, чтобы остальные игроки всегда получали обновлённые д

Свойства класса NetWork и другое

Network.isServer

— является ли текущий компьютер хостом.

Network.isClient

— не является ли текущий компьютер хостом.

Network.isActive

— активна ли сеть.

При создании AmmoType, подходящего для мультиплеера, у него должен быть пустой конструктор (без параметров).
Именно он и вызывается, поэтому, если у вас есть другой конструктор, он вообще не будет работать и вызовет сбой при распаковке сообщения.

this.Fire() синхронизируется в онлайне.
На самом деле, синхронизирован спавн пуль, но если ваше оружие стреляет обычными предметами, вам не нужно предпринимать дополнительных мер, чтобы убедиться, что всё синхронизировано.

OnHoldAction синхронизируется, а OnReleaseAction нет.
Это означает, что вы можете использовать OnHoldAction для зарядки вещей (как это сделано с Phaser). Но вам понадобится какой-то способ убедиться, что OnReleaseAction синхронизирован (например, используя this.Fire, как в Phaser, или с помощью StateBinding).

Соединения и как спавнить вещи

Как упоминалось ранее: У каждой вещи (Thing) есть поле подключения, и все StateBidning значения будут синхронизироваться со значениями этих подключений. А не с хостом/сервером. Тем не мнение, в начале каждого уровня хост будет контролировать все, кроме других уток.
Для таких вещей, как Holdables, есть оптимизация — как только их подбирают, они меняют соединение с утками. Но если вы, например, создаёте какую-то новую вещь, которая что-то делает, вам нужно будет сделать пару дополнительных шагов.Вот всё, что вам нужно сделать, чтобы заспавнить коробку, если ваш мод предназначен для локальной игры:
Level.Add(new Crate(500,500));
Если же ваш мод предназначен для мультиплеера:
if(isServerForObject)
  Level.Add(new Crate(500,500));

Да, вам нужно дополнительное условие, и вот зачем:

isServerForObject

Так как у каждого клиента запущен свой код оружия, заспавнится 2 коробки; одна локально, и вторая от владельца объекта.
isServerForObject проверяет, владеет ли текущий компьютер объектом, чтобы объект заспавнился только один раз.

На самом деле, вы можете проверять, явлвется ли утка локальной, используя isServerForObject.

Мои поздравления! Вы это сделали.

На самом деле, есть ещё одна вещь, о которой вам стоит знать, и это Fondleing. По сути, оно позволяет вам взять конотроль над вещью и установить соединение.

public void Fondle(Thing t)
    {
      if (t == null || !Network.isActive || (!this.isServerForObject || !t.CanBeControlled()) || (this.connection != DuckNetwork.localConnection || t.connection == this.connection))
        return;
      t.connection = this.connection;
      ++t.authority;
}

Это используется для синхронизации вещей, которые вы не подбираете, как, например, RCCar, для управления которым вы подбираете только контроллер. Помните, что Fondle существует. Он спасёт вашу жизнь, когда вы пытаетесь сделать что-то сложное и оно не работает в онлайне.

Если вы хотите увидеть пример, посмотрите код класса RCController и его Update. Это так-же использовано в айтембоксах.

Bitbuffer’ы

BitBuffer’ы содержат данные, которые вы можете посылать остальным. Если вы собираетесь делать более сложные NetMessage, важно знать, как они работают.

По сути, BitBuffer’ы содержат данные и позволяют считывать/записывать их как различные типы. У BitBuffer есть позиция, означающая то, откуда вы в данный момент считываете данные. И по мере считывание значений позиция будет увеличиваться. Таким образом, вам не нужно иметь дело с переменными, а только с порядком того, как они были расставлены.

Запись значений в BitBuffer.

BitBuffer myData = new BitBuffer();
myData.Write((byte)25);
myData.Write((int)50);
myData.Write(true);
myData.Write(new Vec2(25,25));

Считывание значений с того же BitBuffer’а.

BitBuffer sameData = new BitBuffer(myData.buffer);
byte mybyte = sameData.ReadByte();
int myInt = sameData.ReadInt();
bool myBool = sameData.ReadBool();
Vec2 myVec = sameData.ReadVec2();

Очень важно, чтобы порядок чтения и записи был одинаковым, иначе это не будет работать.

Вот пример того, как можно записать массив в BitBuffer, на случай, если это поможет вам понять его немного лучше.

int[] myArray = new int[] { 5,125,123123,67,2,324 };
BitBuffer myData = new BitBuffer();myData.Write(myArray.Length); //размер массива
foreach (var num in myArray)
myData.Write(num); //запись каждого числаBitBuffer sameData = new BitBuffer(myData.buffer);int arraySize = sameData.ReadInt(); //чтение первого int (размера)
int[] theArray = new int[arraySize];for (int i = 0; i < arraySize; i++)
theArray = sameData.ReadInt(); //чтение каждого числа и помещение его в массив[/quote]

Лимит BitBuffer’а.

Каждый BitBuffer’а хранит свой размер в ushost, который является положительным числом от 0 до 2?? или 65535. И так как каждый байт состоит из 8 бит, BitBuffer’а может содержать до 8kb данных.

 

Кастомные Netmessage’ы

Синхронизировать вещи с помощью StateBinding и быть уверенными, что у них правильные владельцы не всегда достаточно для правильной работы.

Как заставить ваши netmessage’ы работать

DuckGame загружает все NetMessage до загрузки модов, поэтому ваши типы NetMessage не будут добавлены в список netmessage’ей.

Для борьбы с этим мы должны добавить наши MessageType’ы.

public static void UpdateNetmessageTypes()
public static void UpdateNetmessageTypes() {
IEnumerable subclasses = Editor.GetSubclasses(typeof(NetMessage));
    Network.typeToMessageID.Clear();
    ushort key = 1;
    foreach (System.Type type in subclasses)
    {
        if (type.GetCustomAttributes(typeof(FixedNetworkID), false).Length != 0) {
            FixedNetworkID customAttribute = (FixedNetworkID)type.GetCustomAttributes(typeof(FixedNetworkID), false)[0];
            if (customAttribute != null)
                Network.typeToMessageID.Add(type, customAttribute.FixedID);
        }
    }
    foreach (System.Type type in subclasses) {
        if (!Network.typeToMessageID.ContainsValue(type)) {
            while (Network.typeToMessageID.ContainsKey(key))
                ++key;
            Network.typeToMessageID.Add(type, key);
            ++key;
        }
    }
}

public override void OnPostInitialize() {
    UpdateNetmessageTypes();
}

public override void OnPostInitialize() {
    UpdateNetmessageTypes();
}

Вуаля, теперь вы можете использовать NetMessage в вашем моде.

По сути, все NetMessage, которые вы будете использовать, это события (events).
Вы так же можете использовать обычные NetMessage(NMDuckNetwork), но только если вы отправляете другим данные, такие как кастомные шапки.

Ваш гипотетический NetMessage работает как-то так:
OnSerilize override заполняет битбаффер serializedData данными из каждого поля в вашем классе (в отличие от StateBidning, свойства не работают), затем они пересылается по интервебам(interwebs), где после получения они проходят через OnDeserilize override, где он помещает все значения из битбаффера в правильные поля.

Для всех Netmessage’ей вам понадобится один пустой конструктор, потому что, когда вы получите сообщение, создастся новый объект этого типа, используя пустой конструктор. Если у вас его нет, вас крашнет (не анимеплеер).

Создание NetMessage’ей.

Вот, как выглядит простой NetMessage:

class NMTimeLimit : NMDuckNetworkEvent
    {
        public string message;
        public NMTimeLimit()
        {

        }

        public NMTimeLimit(string msg)
        {
            message = msg;
        }

        public override void Activate()
        {          
            base.Activate();
            HUD.AddInputChangeDisplay(message);
        }

    }

Вот список всех типов данных, которые будет автоматически сериализованы и десериализованы(очень важно, чтобы поля были public! иначе они не будут сериализованы):

  • string
  • float
  • double
  • byte
  • sbyte
  • bool
  • short
  • ushort
  • int
  • uint
  • long
  • ulong
  • char
  • Vec2
  • Всё, что наследует или является Thing

При применении этого к уткам: используйте byte для netIndex.

duck.profile.networkIndex

а затем найдите утку, используя

DuckNetwork.profiles[(int) index].duck;

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

Серилизация кастомных данных.

А что, если вы хотите какой-то массив? Поскольку он не поддерживается автоматически, нам придется изменить OnSerilize и OnDeserilize overrirides.

int[] myArray = new int[] { 25,30,29};
protected override void OnSerialize()
{
      base.OnSerialize(); //это то, что автоматически сериализирует поля, так что не убирайте это.

      serializedData.Write(myArray.Length);
      foreach (var num in myArray)
             serializedData.Write(num); //то же самое, что и в примере с BitBuffer'ом

public override void OnDeserialize(BitBuffer msg)
{
      base.OnDeserialize(msg); //автоматически десериализирует поля, не убирайте это.
      //так как при сериализации base.OnSerilize выполнялся первым, base.OnDeserilize тоже должен выполняться первым.

      myArray = new int[msg.ReadInt()];
      for (int i = 0; i < myArray.Length; i++)
          myArray[] = msg.ReadInt();
}
//по сути, вам нужно выяснить, как превратить что-либо в BitBuffer
Отправление NetMessages размером > 8kb

Поскольку все в duckgame отправляется с BitBuffer’ами, максимальный размер сообщения Netmessage будет 8 КБ за вычетом размера заголовка (Header (заголовок) содержит информацию о NetMessage, чтобы его можно было снова превратить в сообщение NetMessage из кучи байтов).

Способ, которым DuckGame решает эту проблему, заключается в использовании NMLevelDataHeader, нескольких NMLevelDataChunks, которые являются фрагментами уровня, которые можно собрать вместе, и после их получения он отправит сообщение NMLevelDataReady, чтобы подтвердить, что передача завершена.

Хоть это и работает, я бы не рекомендовал копировать код, поскольку он работает только при выполнении одной передачи одновременно с одним клиентом или всеми клиентами одновременно. Я использовал одно сообщение, содержащее сессию и завершенный bool (session and a finished bool), а затем у меня был менеджер данных, который выяснял, когда новая сессия открывалась, а когда она заканчивалась, используя последний bool. Перед тем, как DuckGame отправит свои NetMessage, он сортирует их по размеру, и поэтому все сообщения, кроме последнего, должны быть одинакового размера, а последнее должно быть меньше, если вы решите просто сложить полученные данные в массив при получении.

Вместо сортировки массива, когда я его отправлял, я решил отсортировать его ранее, когда все данные были получены. Теперь, когда я думаю об этом, я не уверен, какой способ был лучше, но это не имеет большого значения. Во всяком случае, вот код, показывающий, как это можно сделать (из мода автора, Reskins), использующего подход transfermanager: DataTransferSession[github.com]DataTransferSession[github.com] и NMDataSlice[github.com]

Поскольку я неплохой программист и написал гибкий код, вы можете скопировать файлы и использовать функцию DataTransferManager.SendLotsOfData и подключить что-то к событию DataTransferManager.onMessageCompleted, и все должно быть в порядке.

NetDebug

Если у вас всё же возникла потребность протестить что-то, а постоянно просить друзей скачать ваш мод и зайти к вам в лобби — не вариант, специально для вас существует параметр запуска -netdebug.

Чтобы его активировать, кликните ПКМ по игре в библиотеке:

В открывшейся панели введите параметр запуска в соответствующее поле:

Чтобы его активировать, кликните ПКМ по игре в библиотеке:

От Polanas

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

Оставить комментарий

Ваш электронный адрес не будет опубликован.


*