Основы сетевого взаимодействия
Вы уже должны быть знакомы с парадигмой 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 основана на чанках и отправляет состояния компонентов только клиентам, находящимся в пределах дальности чанков от сущности. Это делается по двум основным причинам:
- Это значительно снижает использование пропускной способности, поскольку состояния компонентов не отправляются клиентам, которые даже не могут видеть рассматриваемую сущность.
- Это снижает вероятность читерства, поскольку хакеры физически не получают никаких данных о сущностях, находящихся слишком далеко от них.
Это довольно медленно, но оно многопоточное и во много раз быстрее аналога в BYOND — достаточно быстро, чтобы обеспечить нам >250 игроков при 20 тиках в секунду, так что этого достаточно.
Что касается того, что это значит для вас и вашего кода, учтите, что при тестировании вы не будете получать состояния для сущностей, находящихся слишком далеко, и, возможно, вам придётся подумать об особом случае: что происходит, когда сущности входят в зону видимости и выходят из неё, хотя обычно об этом беспокоиться не нужно.