Руководство по предсказанию

Клиентское предсказание — это техника сетевого программирования, используемая для сокрытия эффектов сетевой задержки. Для вводного объяснения общих концепций в том виде, в каком они используются в большинстве современных многопользовательских игр, рекомендую посмотреть эти два видео:

Как одна программная функция навсегда изменила онлайн-игры

Почему вы замираете в Counter Strike (анализ сетевого кода)

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

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

Смотрите это руководство для базовой информации о том, как передавать компоненты по сети, — эти знания потребуются для данного руководства.

Как работает предсказание?

Без предсказания любой ввод от клиента (например, нажатия клавиш или щелчки мышью) передаётся по сети на сервер, который затем симулирует игру в соответствии с вводом всех игроков и передаёт по сети получившееся игровое состояние обратно каждому клиенту. Клиент увидит результаты лишь с заметной задержкой, зависящей от его пинга, что делает игру неотзывчивой и вызывает визуальную задержку элементов UI.

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

Во время предсказания клиент постоянно совершает путешествия во времени. Когда клиент получает состояние сервера, из-за задержки это игровое состояние будет относиться к игровому тику, лежащему в прошлом с точки зрения клиента. Поэтому клиент должен откатить собственное игровое состояние к этой точке в прошлом, применить информацию, отправленную сервером, и заново просимулировать игру оттуда, попутно снова применяя ввод игрока, пока снова не достигнет текущего игрового тика. Это перезапишет и исправит любые разногласия, которые могли возникнуть у клиента в его предсказанной симуляции (например, кто-то другой убил вас, пока вы пытались с чем-то взаимодействовать, но из-за задержки вы об этом ещё не знали, поэтому взаимодействие отменяется).

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

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

Контрольный список предсказания

Большинство шагов, описанных выше, автоматически обрабатываются игровым движком для любых систем в Content.Shared, но вам нужно позаботиться о правильной передаче вашего кода по сети. Выполните эти шаги, чтобы сделать существующую непредсказываемую EntitySystem предсказываемой:

  • Перенесите соответствующие компоненты и системы из Content.Server в Content.Shared.
  • Добавьте атрибуты NetworkedComponent и AutoGenerateComponentState к компонентам, а атрибут AutoNetworkedField — к полям данных, о которых клиенту нужно знать и которые могут измениться после спавна (если они никогда не отличаются от значения в прототипе, передавать их по сети не нужно), либо при необходимости используйте ручные состояния компонентов.
  • EntitySystem должна быть либо единой неабстрактной системой в Content.Shared, либо абстрактной общей системой, от которой наследуются и сервер, и клиент, если один из них полагается на уникальный, необщий код. Даже если клиентская система пуста, убедитесь, что она существует, иначе код не будет выполняться на клиенте и не будет предсказываться.
  • Чтобы сделать некоторый код предсказываемым, сначала нужно сделать предсказываемыми все его зависимости, поскольку общий код не может вызывать серверный код предсказанным образом.
  • Загрязняйте компонент каждый раз, когда меняются его поля данных. Это скажет серверу отправить клиенту все сетевые поля данных этого компонента, чтобы они синхронизировались, а клиенту — сбросить это поле данных к более раннему значению во время предсказания.
  • Для больших компонентов с множеством сетевых полей данных или в случаях, когда некоторые поля данных изменяются с сильно разной частотой, следует использовать дельта-поля (используйте для этого DirtyField), чтобы снизить сетевую нагрузку. Это загрязнит только это поле данных, а не весь компонент.
  • Используйте предсказанные методы API, например PopupPredicted/PopupClient, PlayPredicted (для звука), PredictedSpawnAtPosition/PredictedSpawnAttachedTo, PredictedDeleteEntity и так далее. Если вы не используете правильный метод для всплывающего сообщения, то увидите его около 10 раз или не увидите вовсе.
  • Убедитесь, что случайность предсказывается, как объясняется ниже.
  • Проверьте всё в игре, чтобы убедиться, что это работает как задумано.

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

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

Вы можете использовать команду sudo cvar net.fakelagmin 0.5, чтобы увеличить фальшивый лаг в режиме разработки. Если предсказание работает, вы не заметите от этого задержки по времени. По умолчанию у неё уже ненулевое значение, но увеличение лага делает любые mispredict гораздо заметнее.

Также я нахожу полезным открыть окно ViewVariables для соответствующего компонента и на сервере, и на клиенте, настроить автоматическое обновление, щёлкнув правой кнопкой по кнопке обновления, и затем сравнивать значения полей данных, пока вы взаимодействуете с сущностью. Вы можете использовать команду quickinspect, чтобы быстро открыть и серверное, и клиентское окно для выбранного вами компонента сущности, не перебирая список компонентов. Если всё работает, значения клиента изменятся мгновенно в момент взаимодействия, а сервер вскоре обновится до тех же значений. Если клиенту приходится как-то себя исправлять или он прыгает между несколькими значениями — это mispredict.

Помогите, мой код на клиенте почему-то выполняется несколько раз!

При установке точек останова или выводе в консоль вы заметите, что предсказываемый код выполняется на клиенте 10+ раз. Это совершенно нормально и является просто работой предсказания. Клиент откатывает игровое состояние к предыдущему тику каждый раз, когда получает состояние сервера, и должен заново просимулировать игру оттуда. Однако для таких вещей, как UI или звук, это требует особого подхода, чтобы предотвратить мерцание или многократное воспроизведение. Подробнее это объясняется ниже.

Пример кода с предсказанием

Давайте рассмотрим простой непредсказываемый пример компонента и сделаем его предсказываемым.

// В Content.Server/PredictionExample/PredictionExampleComponent.cs

using Robust.Shared.Audio;

namespace Content.Server.PredictionExample;

/// <summary>
/// Простой тестовый компонент, который
/// - воспроизводит звук и показывает всплывающее сообщение при взаимодействии, будь то активация или использование действия
/// - имеет текст осмотра
/// </summary>
[RegisterComponent]
public sealed partial class PredictionExampleComponent : Component
{
    /// <summary>
    /// Сколько раз с сущностью взаимодействовали.
    /// </summary>
    [DataField]
    public int Counter = 0;

    /// <summary>
    /// Звук, воспроизводимый при взаимодействии.
    /// </summary>
    [DataField]
    public SoundSpecifier Sound = new SoundPathSpecifier("/Audio/Machines/machine_switch.ogg");
}
// В Content.Server/PredictionExample/PredictionExampleSystem.cs

using Content.Shared.Examine;
using Content.Shared.Interaction;
using Content.Shared.Popups;
using Content.Shared.Verbs;
using Robust.Shared.Audio.Systems;

namespace Content.Server.PredictionExample;

// Эта система серверная и поэтому пока не предсказывается.
public sealed class PredictionExampleSystem : EntitySystem
{
    [Dependency] private readonly SharedAudioSystem _audio = default!;
    [Dependency] private readonly SharedPopupSystem _popup = default!;

    public override void Initialize()
    {
        base.Initialize();

        SubscribeLocalEvent<PredictionExampleComponent, GetVerbsEvent<Verb>>(OnGetVerb);
        SubscribeLocalEvent<PredictionExampleComponent, ActivateInWorldEvent>(OnActivate);
        SubscribeLocalEvent<PredictionExampleComponent, ExaminedEvent>(OnExamine);
    }

    // Простое действие в контекстном меню по правому щелчку
    private void OnGetVerb(Entity<PredictionExampleComponent> ent, ref GetVerbsEvent<Verb> args)
    {
        args.Verbs.Add(new()
        {
            Text = "Interaction Verb", // Это должно быть локализовано, но в этом примере я это пропускаю.
            Act = () => Interact(ent),
        });
    }

    // Событие, которое поднимается при клике по сущности или использовании её в руке.
    private void OnActivate(Entity<PredictionExampleComponent> ent, ref ActivateInWorldEvent args)
    {
        Interact(ent);
    }

    // Увеличивает целочисленное поле данных, показывает всплывающее сообщение и воспроизводит звук.
    public void Interact(Entity<PredictionExampleComponent> ent)
    {
        // Увеличиваем счётчик.
        ent.Comp.Counter++;

        // Воспроизводим звук в месте расположения сущности.
        _audio.PlayPvs(ent.Comp.Sound, ent.Owner);

        // Показываем всплывающее сообщение над сущностью.
        // Это должно быть локализовано, но в этом примере я это пропускаю.
        _popup.PopupEntity($"Interacted {ent.Comp.Counter} times.", ent.Owner);
    }

    // Показываем счётчик в тексте осмотра.
    public void OnExamine(Entity<PredictionExampleComponent> ent, ref ExaminedEvent args)
    {
        // Это должно быть локализовано, но в этом примере я это пропускаю.
        args.PushMarkup($"Interacted {ent.Comp.Counter} times.");
    }
}
# в Resources/Prototypes/test.yml
- type: entity
  parent: BaseItem
  id: ExampleItem
  name: Prediction Example Item
  components:
  - type: Sprite
    sprite: Objects/Fun/Plushies/lizard.rsi
    state: icon
  - type: PredictionExample

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

prediction-guide-unpredicted.gif

Теперь перенесём код в shared, передадим по сети и сделаем предсказываемым.

// В Content.Shared/PredictionExample/PredictionExampleComponent.cs

using Robust.Shared.Audio;
using Robust.Shared.GameStates;

namespace Content.Shared.PredictionExample;

/// <summary>
/// Мы добавляем NetworkedComponent, чтобы сам компонент передавался по сети,
/// и AutoGenerateComponentState, чтобы включить автосетевую передачу для его полей данных.
/// </summary>
[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
public sealed partial class PredictionExampleComponent : Component
{
    /// <summary>
    /// Мы добавляем AutoNetworkedField, чтобы пометить это поле данных как сетевое при загрязнении компонента.
    /// </summary>
    [DataField, AutoNetworkedField]
    public int Counter = 0;

    /// <summary>
    /// Это поле данных не нужно передавать по сети, так как оно никогда не меняется после спавна сущности.
    /// </summary>
    [DataField]
    public SoundSpecifier Sound = new SoundPathSpecifier("/Audio/Machines/machine_switch.ogg");
}

// В Content.Shared/PredictionExample/PredictionExampleSystem.cs

using Content.Shared.Examine;
using Content.Shared.Interaction;
using Content.Shared.Popups;
using Content.Shared.Verbs;
using Robust.Shared.Audio.Systems;

namespace Content.Shared.PredictionExample;

// Теперь эта система предсказывается.
// Нет необходимости делать её абстрактной и наследовать её на сервере и клиенте,
// потому что в этом примере у нас нет кода, эксклюзивного для сервера или клиента.
public sealed class PredictionExampleSystem : EntitySystem
{
    [Dependency] private readonly SharedAudioSystem _audio = default!;
    [Dependency] private readonly SharedPopupSystem _popup = default!;

    public override void Initialize()
    {
        base.Initialize();

        SubscribeLocalEvent<PredictionExampleComponent, GetVerbsEvent<Verb>>(OnGetVerb);
        SubscribeLocalEvent<PredictionExampleComponent, ActivateInWorldEvent>(OnActivate);
        SubscribeLocalEvent<PredictionExampleComponent, ExaminedEvent>(OnExamine);
    }

    // Та же подписка, что и раньше, только в Content.Shared.
    private void OnGetVerb(Entity<PredictionExampleComponent> ent, ref GetVerbsEvent<Verb> args)
    {
        var user = args.User; // Нам нужно сохранить это в переменную, поскольку мы не можем использовать параметры ref внутри лямбда-выражений.
        args.Verbs.Add(new()
        {
            Text = "Interaction Verb",
            Act = () => Interact(ent, user),
        });
    }

    // Та же подписка, что и раньше, только в Content.Shared.
    private void OnActivate(Entity<PredictionExampleComponent> ent, ref ActivateInWorldEvent args)
    {
        Interact(ent, args.User);
    }

    // Теперь нам нужно передавать пользователя, чтобы предсказывать всплывающее сообщение и звук.
    public void Interact(Entity<PredictionExampleComponent> ent, EntityUid user)
    {
        // Увеличиваем счётчик.
        ent.Comp.Counter++;

        // Загрязняем компонент, так как мы изменили одно из его полей данных.
        // Неважно, куда именно мы это поместим, главное, чтобы это произошло в одном и том же игровом тике.
        // Мне нравится вызывать это сразу ниже, чтобы точно не забыть.
        Dirty(ent);

        // Используйте PlayPredicted вместо PlayPvs и передавайте клиента, который предсказал взаимодействие.
        _audio.PlayPredicted(ent.Comp.Sound, ent.Owner, user);

        // Используйте PopupPredicted вместо PopupEntity и передавайте клиента, который предсказал взаимодействие.
        // Это всплывающее сообщение будет видно всем. Если вы хотите показать его только пользователю, используйте вместо него PopupClient.
        _popup.PopupPredicted($"Interacted {ent.Comp.Counter} times.", ent.Owner, user);
    }

    // Та же подписка, что и раньше, только в Content.Shared.
    public void OnExamine(Entity<PredictionExampleComponent> ent, ref ExaminedEvent args)
    {
        args.PushMarkup($"Interacted {ent.Comp.Counter} times.");
    }
}

Результат гораздо более отзывчивый, без задержки для всплывающего сообщения, звука или элементов UI.

prediction-guide-predicted.gif

Зависимости

Общий код может вызывать только другой общий код, тогда как серверный код может использовать и серверный, и общий код. Это означает, что если вы хотите предсказывать EntitySystem, вам сначала нужно предсказать все её зависимости, чтобы использовать их в Shared, что часто превращает PR по предсказанию в гораздо более крупные задачи, чем изначально ожидалось.

Некоторые системы невозможно предсказать, но вам всё же может понадобиться вызывать из Shared некоторые методы API, доступные только на сервере. Чтобы обойти это, вы можете добавить пустой виртуальный метод API в соответствующей общей системе и переопределить его на сервере. Вот пример из SharedExplosionSystem:

// В Content.Shared/Explosion/EntitySystems/SharedExplosionSystem.cs
// Этот метод пуст и ничего не делает на клиенте.
public virtual void TriggerExplosive(EntityUid uid, ExplosiveComponent? explosive = null, bool delete = true, float? totalIntensity = null, float? radius = null, EntityUid? user = null)
{
}

// В Content.Server/Explosion/EntitySystems/ExplosionSystem.cs
// Здесь находится настоящий код, и он выполняется только на сервере, но всё ещё может вызываться из shared, поскольку общий код выполняется и на сервере, и на клиенте.
public override void TriggerExplosive(EntityUid uid, ExplosiveComponent? explosive = null, bool delete = true, float? totalIntensity = null, float? radius = null, EntityUid? user = null)
{
    // Здесь некоторый код, создающий взрыв
}

PopupPredicted и PlayPredicted

Использование SharedPopupSystem.PopupEntity в предсказываемом коде приведёт к тому, что всплывающее сообщение будет показано несколько раз во время предсказания. Вместо этого нужно использовать PopupPredicted, который предскажет всплывающее сообщение один раз для клиента, переданного через параметр user, а сервер передаст это сообщение всем остальным по сети, исключая этого клиента, чтобы он не увидел его дважды. Другие варианты всплывающих сообщений PopopCursor и PopupCoordinates нужно заменить на их предсказанные варианты соответственно. PopupClient работает как PopupEntity и показывает всплывающее сообщение над заданной сущностью, но только для одного клиента, который предсказывает взаимодействие.

Аудио API работает точно так же, как SharedAudioSystem.PlayEntity. Вам придётся использовать PlayPredicted и передавать пользователя, чтобы клиент не воспроизводил звук несколько раз.

Эти API имеют несколько ограничений:

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

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

Предсказанный спавн и удаление сущностей

У IEntityManager есть несколько методов для предсказанного спавна и удаления сущностей. Внутри EntitySystem для них также есть сокращения, смотрите EntitySystem.Proxy.cs.

Спавн

Spawn, SpawnAttachedTo, SpawnAtPosition можно использовать для создания как клиентских, так и серверных сущностей. Пример использования клиентских сущностей — превью спрайтов в меню спавна или руководстве, либо для некоторых визуальных эффектов. Если вы вызовете эти методы в общем коде, и клиент, и сервер создадут сущность по отдельности; сервер передаст свою сущность клиенту, в результате чего получится две разные сущности, одна из которых — дубликат, существующий только для них и не исчезающий. Вместо этого следует использовать PredictedSpawnAttachedTo и PredictedSpawnAtPosition (у Spawn нет предсказанного аналога). Это создаст отдельную клиентскую и серверную сущность, и клиентская будет удалена, когда придёт состояние сервера, а серверная сущность заменит её.

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

В будущем для этого потребуются дальнейшие изменения движка, смотрите Предсказанный спавн сущностей v2.

Удаление

Если вы попытаетесь использовать DeleteEntity или QueueDeleteEntity, чтобы клиент удалил не клиентскую сущность, это вызовет ошибку: [ERRO] root: Predicting the deletion of a networked entity.

Если вы хотите предсказывать удаление сущности, используйте вместо этого эквивалентные PredictedDeleteEntity или PredictedQueueDeleteEntity. Они позволят клиенту сначала переместить сущность в нуль-пространство, из-за чего она как будто исчезнет. Сервер удаляет её обычным образом, а затем передаёт удаление клиенту по сети.

IGameTiming.IsFirstTimePredicted

Это возвращает true при первом выполнении кода и false во всех последующих тиках предсказания, пока клиент ждёт сервер. На сервере это всегда возвращает true (сервер ничего не предсказывает, так что ему в любом случае нужно выполнить код лишь один раз). Обычно это используется внутри API-методов для всплывающих сообщений, звука и некоторого UI-кода в виде guard-условия, чтобы предотвратить многократное выполнение или мерцание во время предсказания.

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

Сейчас IsFirstTimePredicted повсеместно неправильно используется в кодовой базе, чтобы скрыть mispredict, так что не доверяйте в этом существующему коду. Это не решает основную проблему, а скорее отключает предсказание.

IGameTiming.ApplyingState

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

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

В качестве примера для событий контейнера вроде EntInsertedIntoContainerMessage есть два сценария:

A) Вставка сущности в контейнер предсказывается, и событие поднимается локально и на сервере, и на клиенте. Сервер передаёт новое игровое состояние клиенту, клиент обнаруживает, что различий нет, так как изменение контейнера уже было предсказано и ничего исправлять не нужно. Во время предсказания вставки клиент будет быстро поднимать и EntInsertedIntoContainerMessage, и EntRemovedFromContainerMessage, пока откатывает и заново применяет ввод игрока до тех пор, пока не придёт серверное состояние, подтверждающее их.

B) Вставка сущности в контейнер не предсказывается (например, если её вызвал другой игрок), а значит, событие сначала поднимается только на сервере локальным событием. Затем сервер отправляет новое игровое состояние клиенту, который применяет его. При этом клиент вставит сущность в клиентский контейнер, и EntInsertedIntoContainerMessage поднимется на клиенте один раз.

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

Рассмотрим пример из GlueSystem

// GluedComponent сделает предмет временно неудаляемым, если вы его подберёте.
private void OnHandPickUp(Entity<GluedComponent> entity, ref GotEquippedHandEvent args)
{
    // Когда ваш клиент предсказывает подбор предмета, из-за повторяющегося сброса игрового состояния и повторного применения ввода
    // предмет будет быстро вставляться и выпадать из вашей руки (примерно по 10 раз каждый) во время предсказания. 
    // Это будет поднимать и GotEquippedHandEvent, и GotUnequippedHandEvent в чередующемся порядке.
    // Аналогично, когда вы предсказываете выбрасывание приклеенного предмета, предсказание снова вставит предмет в руку при откате состояния к предыдущему.
    // Так что выбрасывание предмета добавило бы UnremoveableComponent на клиенте без этого guard-условия,
    // мешая клиенту снова подобрать его, что вызвало бы mispredict.
    // Однако добавление и удаление UnremovableComponent и установка полей данных уже применяются с тем же игровым состоянием,
    // так что в этом случае нам не нужно ничего делать, что исправляет проблему.
    if (_timing.ApplyingState)
        return;

    var comp = EnsureComp<UnremoveableComponent>(entity);
    comp.DeleteOnDrop = false;
    entity.Comp.Until = _timing.CurTime + entity.Comp.Duration;
    Dirty(entity.Owner, comp);
    Dirty(entity);
}

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

Наиболее распространённые из них EntInsertedIntoContainerMessage EntGotInsertedIntoContainerMessage EntRemovedFromContainerMessage EntGotRemovedFromContainerMessage ContainerIsInsertingAttemptEvent ContainerGettingInsertedAttemptEvent ContainerIsRemovingAttemptEvent ContainerGettingRemovedAttemptEvent DamageChangedEvent HandCountChangedEvent GotEquippedEvent GotEquippedHandEvent GotUnequippedEvent GotUnequippedHandEvent DroppedEvent SolutionChangedEvent SolutionContainerChangedEvent

Пример предсказанного цикла обновления

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

/// <summary>
/// Непредсказываемый пример.
/// </summary>
[RegisterComponent]
public sealed partial class UpdateLoopExampleComponent : Component
{
    /// <summary>
    /// Сколько времени прошло с последнего обновления?
    /// В секундах.
    /// </summary>
    [DataField]
    public float Accumulator = 0f;

    /// <summary>
    /// Интервал времени для цикла обновления (мы не хотим запускать его каждый отдельный тик из соображений производительности).
    /// В секундах.
    /// </summary>
    [DataField]
    public float UpdateInterval = 1f;
}

public sealed class UpdateLoopExampleSystem : EntitySystem
{
    public override void Update(float frameTime)
    {
        // Перебираем все компоненты, игнорируя приостановленные сущности.
        var query = EntityQueryEnumerator<UpdateLoopExampleComponent>();
        while (query.MoveNext(out var uid, out var comp))
        {
            comp.Accumulator += frameTime;

            if (comp.Accumulator < UpdateInterval)
                continue; // С последнего обновления прошло недостаточно времени.
        
            // Сбрасываем аккумулятор.
            comp.Accumulator -= UpdateInterval;
        
            // Делаем что-нибудь здесь.
        }
    }
}

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

/// <summary>
/// Предсказываемый пример.
/// Для этого нам нужно передавать компонент по сети.
/// </summary>
[RegisterComponent, NetworkedComponent]
[AutoGenerateComponentState, AutoGenerateComponentPause]
public sealed partial class UpdateLoopExampleComponent : Component
{
    /// <summary>
    /// Время сервера, в которое произойдёт следующее обновление.
    /// Мы используем AutoPausedField, чтобы это автоматически увеличивалось при приостановке и последующем снятии приостановки сервера.
    /// Иначе все циклы обновления мгновенно сработали бы при снятии приостановки.
    /// Мы используем TimeOffsetSerializer, чтобы это поле данных сериализовалось относительно текущего времени сервера.
    /// Это важно для сохранения, и без этого вы получили бы разные результаты в зависимости от того, когда загружаете сохранение игры.
    /// Это поле данных нужно передавать по сети, чтобы сервер и клиент могли обновляться одновременно.
    /// </summary>
    [DataField(customTypeSerializer: typeof(TimeOffsetSerializer))]
    [AutoNetworkedField, AutoPausedField]
    public TimeSpan NextUpdate = TimeSpan.Zero;

    /// <summary>
    /// Интервал времени для цикла обновления (мы не хотим запускать его каждый отдельный тик из соображений производительности).
    /// Не нужно передавать по сети, так как в этом примере это значение остаётся постоянным.
    /// </summary>
    [DataField]
    public TimeSpan UpdateInterval = TimeSpan.FromSeconds(1);
}

public sealed class UpdateLoopExampleSystem : EntitySystem
{
    [Dependency] private readonly IGameTiming _timing = default!;

    public override void Initialize()
    {
        SubscribeLocalEvent<UpdateLoopExampleComponent, MapInitEvent>(OnMapInit)
    }
    
    private void OnMapInit(Entity<UpdateLoopExampleComponent> ent, ref MapInitEvent args)
    {
        // Задаём время первого обновления после спавна сущности.
        // Без этого оно обновлялось бы каждый отдельный тик, пока NextUpdate не догонит время сервера.
        ent.Comp.NextUpdate = _timing.CurTime + ent.Comp.UpdateInterval;
        Dirty(ent);
    }

    public override void Update(float frameTime)
    {
        // CurTime вычисляется так, чтобы мы делали это один раз вне цикла обновления, а не для каждой отдельной сущности.
        var curTime = _timing.Curtime;
        // Перебираем все компоненты, игнорируя приостановленные сущности.
        var query = EntityQueryEnumerator<UpdateLoopExampleComponent>();
        while (query.MoveNext(out var uid, out var comp))
        {
            if (comp.NextUpdate < curTime)
                continue; // С последнего обновления прошло недостаточно времени.
        
            // Задаём время для следующего обновления.
            // Не используйте
            // comp.NextUpdate = curTime + UpdateInterval;
            // потому что это съедает остаток при каждом обновлении, из-за чего цикл обновления запускается чуть реже,
            // чем задано в UpdateInterval, что будет неточным и может вызвать проблемы на больших промежутках времени.
            comp.NextUpdate += UpdateInterval;
        
            // Загрязняем компонент, чтобы клиент мог пересчитать поле данных NextUpdate во время предсказания.
            // Без этого вы получите mispredict.
            Dirty(uid, comp);
        
            // Делаем что-нибудь здесь.
        }
    }
}

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

PVS

Чтобы снизить сетевую нагрузку, сервер передаёт по сети только сущности в пределах определённого диапазона (по умолчанию квадрат 25x25 вокруг сущности, к которой привязан клиент). Если сущность покидает диапазон PVS, она приостанавливается, так что циклы обновления больше не выполняются на клиенте, и открепляется в нуль-пространство на клиенте, пока снова не войдёт в диапазон PVS.

Если хотите увидеть это в действии, можете полетать админ-призраком с отдалённым видом.

prediction-guide-pvs.gif

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

Нуль-пространство

Нуль-пространство — это пустая карта по умолчанию, на которой создаются сущности, если вы не указываете место спавна. Обычно оно используется для сущностей, представляющих «абстрактные» данные — вещи, которые нужно отслеживать, но у которых нет реального физического местоположения.

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

Переопределения PVS

Если вы хотите передать по сети или предсказать что-то за пределами диапазона PVS, вам понадобится переопределение PVS. Используйте их экономно, так как они добавляют дополнительную сетевую нагрузку.

  • AddSessionOverride: делает конкретную сущность всегда видимой для данного игрока, пока переопределение снова не будет снято. Пример использования — способность волшебника по возврату предмета, которая телепортирует ранее отмеченную далёкую сущность обратно вам в руку.
  • AddGlobalOverride: делает эту сущность всегда видимой для всех игроков, независимо от PVS. Пример — сингулярность, у которой это есть из-за большого радиуса действия эффекта оверлея искажения: без переопределения вы бы видели, как она внезапно появляется в диапазоне PVS.
  • AddViewSubscriber: позволяет игроку видеть все сущности в пределах диапазона PVS заданной сущности, пока подписка не отменена. Пример использования — камеры, которые позволяют игроку наблюдать за далёкими местами через второй видовой экран.

Сетевое взаимодействие для конкретной сессии

По умолчанию все клиенты получают полную информацию обо всех сетевых компонентах и их полях данных в пределах диапазона PVS. Хотя они не могут прочитать всю эту информацию, не будучи администратором и не используя окно ViewVariables, некоторые читеры всё же могут использовать это для получения иначе скрытой информации, доступной на их клиенте, например статуса антагониста или наличия у кого-то контрабанды в инвентаре. Поэтому будьте осторожны с информацией, которую передаёте по сети для целей предсказания, и при необходимости используйте следующие инструменты, чтобы ограничить её только теми клиентами, которым нужно знать.

SendOnlyToOwner

У всех компонентов есть bool SendOnlyToOwner, который заставит компонент передаваться по сети игроку только в том случае, если он привязан к сущности, которой принадлежит компонент. Это полезно для некоторых способностей или черт предателя, о которых нужно знать только пользователю. Пример — PacifiedComponent, который лишает вас возможности атаковать других, но остальным игрокам не нужно знать о нём для целей предсказания, поскольку они не могут предсказать ввод с клавиатуры от пацифицированного игрока (сервер должен сначала отправить его им).

SessionSpecific

У всех компонентов есть bool SessionSpecific, который заставит поднимать ComponentGetStateAttemptEvent на владеющей сущности для каждого игрока, которому передаётся компонент, и позволяет отменить передачу в подписке. Учтите, что это связано с некоторыми накладными расходами на производительность, поскольку может подниматься множество событий. Пример — SharedRevolutionarySystem, который использует это, чтобы только игроки-революционеры и администраторы знали, кто ещё является революционером. Однако сейчас это API довольно неудобно использовать, и оно требует много шаблонного кода. Возможно, в будущем это можно будет упростить до белого списка компонентов, определяющего, кто может видеть определённый компонент, специфичный для сессии.

Предсказанная случайность

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

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

prediction-guide-mispredict.gif

В будущем в Robust Toolbox появятся методы для предсказанной случайности, но на момент написания PR для RandomPredicted ещё не был смёржен. В качестве обходного пути вы можете использовать новый экземпляр System.Random и задать начальное значение, с которым согласны и сервер, и клиент, например комбинацию NetEntity-id сущности и текущего игрового тика (если бы вы использовали здесь только игровой тик, то вся случайность в пределах одного игрового тика давала бы одинаковый результат, так что нужны оба). Для этого есть вспомогательный метод в SharedRandomExtensions.

// Зависимости EntitySystem:
// [Dependency] private readonly IGameTiming _timing = default!;
// [Dependency] private readonly IRobustRandom _random = default!;

// Это вызовет mispredict:
bool randomBool = _random.Prob(0.5f);
double randomDouble = _random.NextDouble();

// Вместо этого используйте:
bool randomBoolPredicted = SharedRandomExtensions.PredictedProb(_timing, 0.5f, GetNetEntity(uid)); // Вспомогательный метод, который сразу возвращает bool.
var rand = SharedRandomExtensions.PredictedRandom(_timing, GetNetEntity(uid)); // Создаёт новый экземпляр System.Random, одинаковый на сервере и клиенте.
double randomDoublePredicted = rand.NextDouble(); // Создаём из него случайное число.

// То же самое для любого другого метода в IRobustRandom, поскольку это просто обёртка над System.Random.
// Если вы генерируете несколько случайных чисел в одном тике для одной сущности, то можете переиспользовать экземпляр System.Random вместо создания нового при каждом вызове.

Будьте с этим осторожны, так как читер, знающий соответствующий NetEntity-id, теоретически может повлиять на результат, если дождётся нужного игрового тика, чтобы отправить ввод пользователя. Поэтому любые игровые функции, которые могли бы дать вам серьёзное преимущество, например случайный спавн предметов, скидки на телекристаллы в магазинах, выбор антагониста или цели и т. п., лучше оставлять непредсказываемыми, если игрок способен точно подгадать их по времени.

WeakEntityReference

Согласно соглашению, используемому в нашей ECS, EntityUid всегда должен ссылаться на допустимую, существующую сущность. Если у вас есть автосетевое поле данных типа EntityUid, генератор исходного кода преобразует его в NetEntity, отправит клиенту и преобразует обратно в соответствующий EntityUid на клиенте (у которого будет другой id, отличный от серверного). Чтобы получить NetEntity, серверу придётся прочитать MetaDataComponent сущности. Однако если сущность каким-то образом удалена (например, когда её съела сингулярность, переработали, превратили в фарш или использовали для крафта), компонент будет удалён вместе с ней. Это приводит к ошибке, когда сервер пытается передать по сети компонент, содержащий EntityUid, ссылающийся на удалённую сущность:

Can't resolve "Robust.Shared.GameObjects.MetaDataComponent" on entity 957132/n0D!
   at System.Environment.get_StackTrace()
   at Robust.Shared.GameObjects.EntityManager.GetNetEntity(EntityUid uid, MetaDataComponent metadata) in /home/runner/work/space-station-14/space-station-14/RobustToolbox/Robust.Shared/GameObjects/EntityManager.Network.cs:line 186
...

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

На момент написания серверы WizDen получают более 20000 таких ошибок в день, и для их устранения потребуются некоторые изменения движка, вводящие WeakEntityReference, который ссылается на сущность, которая может быть удалена, а может и нет. В качестве альтернативы может быть введена система отношений, которая будет автоматически сбрасывать ссылающееся поле данных обратно в null. После слияния PR движка репозиторию контента потребуется некоторая чистка, чтобы избавиться от этих ошибок.

Смотрите этот issue для подробностей.

NetSync

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

Предсказание BUI

BoundUserInterfaces обычно отправляют всю необходимую им информацию на клиенте с помощью BoundUserInterfaceState, что похоже на ручную передачу по сети. Но если мы уже передаём по сети соответствующие поля данных, то у клиента уже есть вся эта информация, и состояние BUI — это просто дублирующая передача по сети. Вместо этого мы можем полностью убрать состояние BUI и просто читать нужную информацию напрямую из компонента с помощью TryComp на клиенте. Затем интерфейс можно обновлять в подписке на AfterAutoHandleStateEvent (не забудьте активировать её, установив параметр raiseAfterAutoHandleState в AutoGenerateComponentStateAttribute), так что он будет обновляться всякий раз, когда меняется поле данных в соответствующем компоненте. Если вам нужно обновлять BUI при вставке или удалении сущности из контейнера, это можно сделать с помощью подписки на EntInsertedIntoContainerMessage или EntRemovedFromContainerMessage.

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

Чтобы BUI также обновлялся во время предсказания, а не только при получении состояния сервера, вам также понадобится пустой виртуальный метод UpdateUi в вашем общем коде, который в клиентском переопределении будет вызывать метод Update BUI.

Хороший пример кода для предсказания BUI с использованием состояний компонентов можно найти в этом PR.

На что обратить внимание

Советы по производительности

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

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

// внутри EntitySystem

public void SetExampleDataField(Entity<ExampleComponent?> ent, int newValue)
{
    if (!Resolve(ent, ref ent.Comp))
        return; // ничего не делаем, если у сущности нет компонента
    
    if (ent.Comp.ExampleDataField == newValue)
        return; // ничего не изменилось, загрязнять не нужно
        
    // устанавливаем поле данных и загрязняем компонент
    ent.Comp.ExampleDataField = newValue;
    Dirty(ent);
}

Рекомендуется давать вашим компонентам ограничения доступа с помощью [Access(typeof(SomeSystem))], чтобы только соответствующая система могла задавать его поля данных через методы-сеттеры API, как в примере выше. Это гарантирует, что другие системы смогут изменять компонент только предусмотренными способами и не смогут забыть загрязнить компонент, а хорошее API делает будущие изменения компонента менее ломающими.

Запуск предсказанного кода с сервера

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

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

IRobustCloneable

Предсказание повторно сбрасывает любое загрязнённое поле данных обратно к предыдущему игровому состоянию и заново симулирует ввод игрока. Однако если ваше поле данных — это ссылочный тип, оно сбросит только ссылку на это поле данных, а не текущее значение поля данных. Это может привести к mispredict и даже к серьёзным графическим глюкам, если обрабатывать это неправильно. Чтобы исправить это, обязательно реализуйте интерфейс IRobustCloneable при использовании автосетевой передачи с любым собственным ссылочным типом, который вы передаёте, и генератор исходного кода позаботится о создании глубокой копии поля данных для каждого игрового состояния. Пример кода для этого — класс Solution, используемый в SolutionComponent.

Соглашения для общих систем и компонентов

EntitySystem должны быть либо неабстрактными и общими, например:

// В Content.Shared/SomeNamespace/SomeSystem.cs
public sealed class SomeSystem : EntitySystem
{
    // ...
}

либо иметь общую абстрактную систему, наследуемую на сервере и клиенте.

// В Content.Shared/SomeNamespace/SharedSomeSystem.cs
public abstract class SharedSomeSystem : EntitySystem
{
    // ...
}

// В Content.Server/SomeNamespace/SomeSystem.cs
public sealed class SomeSystem : SharedSomeSystem
{
    // ...
}

// В Content.Client/SomeNamespace/SomeSystem.cs
public sealed class SomeSystem : SharedSomeSystem
{
    // ...
}

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

Избегайте общих абстрактных компонентов. Некоторый древний код всё ещё так делает, но сегодня мы вместо этого просто помещаем весь компонент в Content.Shared, даже если некоторые поля данных используются только на сервере или клиенте. Это минимально хуже для производительности, но делает код гораздо более читабельным, упрощает любые методы API и облегчает использование TryComp и Resolve.

Debug-assert в UdderSystem

Сейчас наблюдается странное поведение с сущностями растворов и PVS, которое может вызывать debug-assert’ы при определённых обстоятельствах и потребует обходного пути, смотрите этот PR. Я упоминаю это здесь, потому что иначе в этом сложно разобраться.

Этот debug-assert происходит, если предсказанный цикл обновления вызывает SharedSolutionContainerSystem.ResolveSolution, а сущность, содержащая сущность раствора, покидает диапазон PVS. Этого не должно происходить, поскольку сущности приостанавливаются при перемещении за пределы диапазона PVS, а значит, цикл обновления больше не должен выполняться на клиенте, но по какой-то причине это всё равно вызывает debug-assert и требует обходного пути, пока это не будет исправлено должным образом так, чтобы не нужен был этот шаблонный код.

private void OnEntRemoved(Entity<UdderComponent> entity, ref EntRemovedFromContainerMessage args)
{
    // Убеждаемся, что удалённая сущность была нашим содержащимся раствором
    if (entity.Comp.Solution == null || args.Entity != entity.Comp.Solution.Value.Owner)
        return;

    // Очищаем нашу кэшированную ссылку на сущность раствора
    entity.Comp.Solution = null;
}

Это гарантирует, что кэшированная ссылка на сущность раствора сбрасывается в null, когда она покидает диапазон PVS и открепляется в нуль-пространство.

Смотрите этот issue о текущем состоянии этой проблемы.

Используйте NetworkedComponentAttribute только для общих компонентов

Добавление [NetworkedComponent] к чисто серверному или клиентскому компоненту (т. е. не находящемуся в Content.Shared) не имеет никакого смысла, поскольку такие в принципе не могут передаваться по сети. Однако на момент написания RobustToolbox не мешает вам это делать и вместо создания предупреждения или ошибки просто ломается молча. Симптомы включают то, что случайные другие компоненты перестают передаваться клиенту, что вызывает огромный спектр багов, например mispredict и незаполненные UI. Так что за этим стоит следить при ревью или при написании нового кода.

Смотрите этот issue о текущем состоянии этой проблемы.

Subpages