Основы сетевого взаимодействия

Вы уже должны быть знакомы с парадигмой Client/Shared/Server, которую использует Robust. Если нет, вам следует прочитать предыдущую документацию.

SS14 — многопользовательская игра! Это очень важный факт, и с ним, скорее всего, придётся часто считаться при написании кода. Правильно продумывать это важно, чтобы всё шло гладко и не возникало потенциальных упущений в безопасности.

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

Состояния компонентов

Состояние компонента простое — это всего лишь класс данных, наследующий ComponentState. Они определяют, какие данные отправляются клиенту, и строятся из данных внутри компонентов.

Откуда игра знает, когда отправлять эти данные? Очевидно, она не отправляет их постоянно — это крайне избыточно и ужасно сказалось бы на производительности и пропускной способности. Вместо этого серверная система должна вызвать Dirty(EntityUid uid, Component component), что помечает сущность как «грязную», означая, что в следующем тике для неё будет создано и отправлено новое состояние компонента.

Существуют два особых события для помещения данных в состояния компонентов и извлечения данных из них: ComponentGetState и ComponentHandleState. GetState всегда вызывается на сервере, а HandleState вызывается на клиенте. Однако обе подписки на события можно разместить в Shared, и это всё равно будет работать как ожидается!

Автоматическая генерация состояний компонентов

Robust Toolbox поддерживает использование генераторов исходного кода, чтобы значительно упростить сетевую передачу состояний компонентов. В большинстве ситуаций это гораздо предпочтительнее попыток делать это вручную. Это работает за счёт использования возможности C# анализировать код до его компиляции и автоматически генерировать шаблонный исходный код.

Во-первых, ваш компонент и все сетевые поля должны находиться в Content.Shared, а класс компонента должен быть помечен атрибутом [NetworkedComponent], который в первую очередь включает сетевое взаимодействие.

Чтобы использовать генератор исходного кода для автоматической репликации полей, сделайте класс компонента partial, пометьте его атрибутом [AutoGenerateComponentState] и отметьте все поля, которые хотите передавать по сети, атрибутом [AutoNetworkedField]. Затем, когда вы пометите компонент как грязный (или когда он впервые добавляется к сущности), он должен просто работать™️, и у клиента будут все сетевые поля.

Если у вас есть код в handle state, который вызывает некоторую функцию после установки поля (например, обновление внешнего вида), измените атрибут состояния компонента на [AutoGenerateComponentState(true)], и тогда вы сможете подписаться по ссылке (by-ref) на AfterAutoHandleStateEvent и делать что-то там!

Если ваше поле требует клонирования для целей предсказания (например, dict), вы можете изменить атрибут поля на [AutoNetworkedField(true)]. Если вам нужно более сложное сетевое взаимодействие, следует использовать ручной метод.

Пример всего сетевого кода, необходимого теперь для IDCardComponent, из https://github.com/space-wizards/space-station-14/pull/14845:

// IDCardComponent.cs
[RegisterComponent, NetworkedComponent]
[AutoGenerateComponentState]
public sealed partial class IdCardComponent : Component
{
    [DataField]
    [AutoNetworkedField]
    public string? FullName;

    [DataField]
    [AutoNetworkedField]
    public string? JobTitle;
}

Ручная обработка состояний компонентов

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

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

// AmbientSoundComponent.cs
    [Serializable, NetSerializable]
    public sealed class AmbientSoundComponentState : ComponentState
    {
        public bool Enabled { get; init; }
        public float Range { get; init; }
        public float Volume { get; init; }
    }

Это определение довольно простого состояния компонента. Оно помечено [Serializable, NetSerializable], что требуется для любого объекта, отправляемого по сети. Этот класс определяет три переменные, которые хочет синхронизировать с клиентом: включён ли этот звук, его диапазон и его громкость.

Посмотрим, как это состояние конструируется на сервере:

/// SharedAmbientSoundSystem.cs
        public override void Initialize()
        {
            base.Initialize();
            SubscribeLocalEvent<AmbientSoundComponent, ComponentGetState>(GetCompState);
            SubscribeLocalEvent<AmbientSoundComponent, ComponentHandleState>(HandleCompState);
        }

        ...

// В обработчиках событий..
        private void GetCompState(Entity<AmbientSoundComponent> ent, ref ComponentGetState args)
        {
            args.State = new AmbientSoundComponentState
            {
                Enabled = ent.Comp.Enabled,
                Range = ent.Comp.Range,
                Volume = ent.Comp.Volume,
            };
        }

Важно отметить, что в аргументах обработчика событий используется синтаксис ref ComponentGetState args, а не просто ComponentGetState args. Это требуется для некоторых событий, так как они являются значимыми типами, поднимаемыми «по ссылке», а не просто классами, наследующими EntityEventArgs. Это сделано ради производительности и не так уж важно, но полезно запомнить, так как забытый ref может привести к ошибкам времени выполнения, которые могут сбить с толку.

Чтобы указать состояние для отправки, вы просто задаёте поле State события своим новым состоянием компонента, сконструированным из значений серверного компонента. Легко!


А теперь на клиенте (технически всё ещё в shared, но этот код выполняется только на клиенте!)

/// SharedAmbientSoundSystem.cs
        ...

        private void HandleCompState(Entity<AmbientSoundComponent> ent, ref ComponentHandleState args)
        {
            if (args.Current is not AmbientSoundComponentState state)
                return;

            ent.Comp.Enabled = state.Enabled;
            ent.Comp.Range = state.Range;
            ent.Comp.Volume = state.Volume;
        }

Снова обратите внимание на ref.

Первая строка метода выполняет хитрое сопоставление с образцом C#, чтобы привести поле Current в аргументах события к искомому состоянию, поскольку это довольно обобщённое событие.

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

Пример сетевого взаимодействия компонента

В качестве высокоуровневого примера посмотрим, как атмосферные вентиляции обрабатывают свои окружающие звуки.

/// GasVentPumpSystem.cs
            ...
            _ambientSoundSystem.SetAmbience(uid, true);
            if (!vent.Enabled)
            {
                _ambientSoundSystem.SetAmbience(uid, false);
            ...

Вентиляция сначала устанавливает своё звуковое окружение в true по умолчанию. Однако если вентиляция не включена, она отключает звуковое окружение.

Однако всё это в серверном коде! В функции SetAmbience система звукового окружения вызывает Dirty для сущности, что сообщает серверу, что данные этой сущности обновлены и клиента нужно об этом уведомить. Затем, в следующем тике, сервер поднимает событие ComponentGetState на вентиляции, и состояние окружающего звука создаётся и отправляется.

Как только клиент получает его (после задержки), он поднимает ComponentHandleState на вентиляции, что приводит к корректному отключению окружающего звука на клиенте. Аккуратно!

Сетевые события

Другой основной способ общения сервера и клиента, помимо репликации через состояния компонентов, — это сетевые события и низкоуровневые NetMessage.

Сетевые события противопоставляются локальным событиям (RaiseLocalEvent или SubscribeLocalEvent — знакомо?), которые являются исключительно «локальными» для той стороны сети, на которой они были подняты, тогда как сетевые события отправляются исключительно по сети. Сетевые события используют аналогичные RaiseNetworkEvent и SubscribeNetworkEvent.

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

NetMessage — низкоуровневый аналог сетевых событий (фактически сетевые события сами создают NetMessage). Вам следует избегать их использования, если вы не знаете, что делаете, поэтому здесь я не буду их рассматривать, кроме упоминания.

Пример

Посмотрим на административные запросы помощи (также называемые системой bwoink) и узнаем, как она отправляет произвольные данные, не привязанные к сущности, клиентам.

Вот как определяется сетевое событие:

/// SharedBwoinkSystem.cs

...
    
        [Serializable, NetSerializable]
        public sealed class BwoinkTextMessage : EntityEventArgs
        {
            public DateTime SentAt { get; }
            public NetUserId ChannelId { get; }
            public NetUserId TrueSender { get; }
            public string Text { get; }

            public BwoinkTextMessage(NetUserId channelId, NetUserId trueSender, string text, DateTime? sentAt = default)
            {
                SentAt = sentAt ?? DateTime.Now;
                ChannelId = channelId;
                TrueSender = trueSender;
                Text = text;
            }
        }

Заметьте, что это по сути идентично обычному классу данных события, за исключением того, что он помечен NetSerializable, по тем же причинам, что упоминались выше для состояний компонентов. Я не буду разбирать здесь конкретные данные — полагаю, вы поняли идею.

Интересно в этом событии то, что оно поднимается и обрабатывается как на клиенте, так и на сервере. Поэтому рассмотрим их отдельно.

От клиента к серверу

/// Content.Client ... BwoinkSystem.cs
...
        public void Send(NetUserId channelId, string text)
        {
            // Повторно используем ID канала в качестве «настоящего отправителя».
            // Сервер проигнорирует это, и если кто-то заставит его не игнорировать (что плохо, позволяет выдавать себя за других!!!), это поможет.
            RaiseNetworkEvent(new BwoinkTextMessage(channelId, channelId, text));
        }

Send здесь вызывается всякий раз, когда на клиенте вводится текст в поле ввода UI BWOINK (tm):

/// BwoinkPanel.xaml.cs
...
        private void Input_OnTextEntered(LineEdit.LineEditEventArgs args)
        {
            if (string.IsNullOrWhiteSpace(args.Text))
                return;

            _bwoinkSystem.Send(ChannelId, args.Text);
            SenderLineEdit.Clear();
        }
...

Достаточно просто! Клиент вводит сообщение, нажимает Enter, затем система Bwoink создаёт сетевое событие из своего сообщения и отправляет его на сервер. Посмотрим, как оно обрабатывается на сервере:

Обработка на сервере

Итак, обработчик здесь немного великоват, поэтому я сокращу его до важных моментов:

/// Content.Server ... BwoinkSystem.cs

        // вообще-то это в shared и переопределяется на сервере/клиенте, но для простоты вы поняли идею..
        public override void Initialize()
        {
            base.Initialize();

            SubscribeNetworkEvent<BwoinkTextMessage>(OnBwoinkTextMessage);
        }

        ...

        protected override void OnBwoinkTextMessage(BwoinkTextMessage message, EntitySessionEventArgs eventArgs)
        {
            base.OnBwoinkTextMessage(message, eventArgs);
            var senderSession = (IPlayerSession) eventArgs.SenderSession;

            // TODO: Очистить текст?
            // Убедиться, что этому человеку действительно разрешено отправлять здесь сообщение.
            var personalChannel = senderSession.UserId == message.ChannelId;
            var senderAdmin = _adminManager.GetAdminData(senderSession);
            var authorized = personalChannel || senderAdmin != null;
            if (!authorized)
            {
                // Неавторизованный bwoink (логировать?)
                return;
            }

            var escapedText = FormattedMessage.EscapeText(message.Text);

            var bwoinkText = ...

            var msg = new BwoinkTextMessage(message.ChannelId, senderSession.UserId, bwoinkText);

            ...
            
            // Администраторы
            var targets = _adminManager.ActiveAdmins.Select(p => p.ConnectedClient).ToList();

            // И участвующий игрок
            if (_playerManager.TryGetSessionById(message.ChannelId, out var session))
                if (!targets.Contains(session.ConnectedClient))
                    targets.Add(session.ConnectedClient);

            foreach (var channel in targets)
                RaiseNetworkEvent(msg, channel);
            
            ...

Одна вещь, которую вы заметите, — это сигнатура функции: сетевые события снова не привязаны к сущности, поэтому здесь всего два аргумента — само событие и сессия, отправившая его (если это событие приходит от клиента!)

Первое, что делает этот обработчик, — самое важное: он выясняет, действительно ли клиенту разрешено отправлять это сообщение помощи. Он делает это, проверяя, что игрок на самом деле находится в активном обращении или является администратором. Достаточно просто. Всегда проверяйте события, отправленные с клиента!

Далее он повторно передаёт это сообщение различным сторонам, чтобы они тоже могли его увидеть. Сначала он получает всех активных администраторов, затем активного игрока в текущем обращении и отправляет то же сообщение обратно им.

Обработка на клиенте

Вернёмся к клиенту, чтобы посмотреть, что он делает с этим новым текстовым сообщением, отправленным с сервера.

/// Content.Client ... BwoinkSystem.cs
        // вообще-то это в shared и переопределяется на сервере/клиенте, но для простоты вы поняли идею..
        public override void Initialize()
        {
            base.Initialize();

            SubscribeNetworkEvent<BwoinkTextMessage>(OnBwoinkTextMessage);
        }

        ...

        protected override void OnBwoinkTextMessage(BwoinkTextMessage message, EntitySessionEventArgs eventArgs)
        {
            base.OnBwoinkTextMessage(message, eventArgs);
            LogBwoink(message);
            // Собственно строка
            var window = EnsurePanel(message.ChannelId);
            window.ReceiveLine(message);
            // Проиграть звук, если это отправили не мы
            var localPlayer = _playerManager.LocalPlayer;
            if (localPlayer?.UserId != message.TrueSender)
            {
                SoundSystem.Play(Filter.Local(), "/Audio/Effects/adminhelp.ogg");
                _clyde.RequestWindowAttention();
            }

            _adminWindow?.OnBwoink(message.ChannelId);
        }

Этот понять легко, поэтому я его совсем не сокращал. Он сначала открывает окно помощи администрации для пользователя, отправляет строку в окно, чтобы её можно было увидеть, а затем проигрывает Забавный Звук (если отправили не они). Здесь не выполняется никакой проверки (более или менее), потому что мы можем доверять серверу в отправке точных данных клиенту.

Потенциально видимое множество (PVS)

Вы, вероятно, слышали, как этот термин упоминают в разработке, поскольку он имеет большое значение.

Подумайте на секунду — в этой игре чертовски много сущностей! И, скорее всего, многие из них постоянно вызывают Dirty, а клиентов тоже будет много. Как нам понять, как отправлять эти состояния каждому клиенту? Ответ — система потенциально видимого множества, или PVS.

Это не будет сверхнизкоуровневым обзором или чем-то подобным, но, по сути, PVS основана на чанках и отправляет состояния компонентов только клиентам, находящимся в пределах дальности чанков от сущности. Это делается по двум основным причинам:

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

Это довольно медленно, но оно многопоточное и во много раз быстрее аналога в BYOND — достаточно быстро, чтобы обеспечить нам >250 игроков при 20 тиках в секунду, так что этого достаточно.

Что касается того, что это значит для вас и вашего кода, учтите, что при тестировании вы не будете получать состояния для сущностей, находящихся слишком далеко, и, возможно, вам придётся подумать об особом случае: что происходит, когда сущности входят в зону видимости и выходят из неё, хотя обычно об этом беспокоиться не нужно.

Subpages