Соглашения

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

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

О том, как организованы файлы и папки в кодовой базе SS14, читайте в разделе Организация кодовой базы.

Прочитайте руководство по pull request’ам, чтобы узнать, как сделать ваш код более удобным для проверки мейнтейнерами.

Info

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

Общие соглашения программирования

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

Не копируйте код

Если вы когда-нибудь смотрите на другой фрагмент кода и думаете «Я хочу сделать то же самое»: НЕ КОПИРУЙТЕ его. Создайте новую функцию или другую абстракцию, позволяющую повторно использовать как можно больше кода.

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

Конечно, есть места, где вы можете подумать, что «копируете» неизбежный код. Например, базовая структура для создания EntitySystem, который что-то делает, всегда содержит определение класса, несколько зависимостей, override void Initialize() и так далее. Такой «шаблонный» код копировать нормально, поскольку его действительно никак не избежать.

Не используйте магические строки/числа

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

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

В простейшем случае такие магические значения следует просто хранить в const или static readonly, на который ссылаются из нескольких мест, чтобы компилятор C# гарантировал их постоянное совпадение. Если вам нужно сослаться на ID прототипа из C#, определите ID прототипа в static readonly ProtoId<T>, поскольку наши инструменты валидации гарантируют, что ID в этих полях всегда допустимы.

Комментарии

  • Комментируйте код на высоком уровне, объясняя, что делает код, и, что важнее, почему код делает то, что делает.

  • При документировании классов, структур, методов, свойств/полей и членов классов используйте XML-документацию. DataField и публичные методы всегда должны быть задокументированы.

    • Пример:
        /// <summary>
        /// Сбрасывает InteractCounter у <see cref="FooComponent"/>.
        /// </summary>
        /// <remarks>
        /// Это публичный метод, который могут вызывать другие системы для взаимодействия с FooComponent!
        /// Помните, что публичные методы всегда должны использовать docstring.
        /// </remarks>
        [PublicAPI]
        public void ResetInteractCounter(Entity<FooComponent?> ent)
    

Почему, а не что

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

Пример 1

var fractionalPressureChange = Atmospherics.R * (outlet.Air.Temperature / outlet.Air.Volume + inlet.Air.Temperature / inlet.Air.Volume);

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

// Взять R и умножить на отношение температуры на выходе, делённой на объём воздуха на выходе, и прибавить к ...
var fractionalPressureChange = Atmospherics.R * (outlet.Air.Temperature / outlet.Air.Volume + inlet.Air.Temperature / inlet.Air.Volume);

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

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

// Мы хотим, чтобы количество переносимых молей было пропорционально разнице давлений, то есть
// dn/dt = G*P

// Чтобы решить это, нужно выразить dn через P. Поскольку PV=nRT, dP/dn=RT/V.
// Здесь предполагается, что изменение температуры от переноса dn молей пренебрежимо мало.
// Поскольку P=Pi-Po, то dP/dn = dPi/dn-dPo/dn = R(Ti/Vi - To/Vo):
var dPdn = Atmospherics.R * (outlet.Air.Temperature / outlet.Air.Volume + inlet.Air.Temperature / inlet.Air.Volume);

Пример 2

if (HasComp<MindContainerComponent>(uid))
    return;

// дальнейший код

Очевидно, этот код пропускает «дальнейший код», если сущность, представленная uid, уже имеет MindContainerComponent. Этот код настолько самодокументируемый, насколько это вообще возможно: он буквально просто делает ранний выход, если есть MindContainer. Задокументировать нужно почему этот код должен пропускать uid, у которых уже есть MindContainerComponent:

// Не позволять игрокам, выпившим когнизин, быть доступными для вселения призрака
if (HasComp<MindContainerComponent>(uid))
    return;

Строки и идентификаторы

Человекочитаемый текст никогда не должен использоваться в качестве идентификатора, и наоборот. В одну сторону это означает, что нельзя помещать человекочитаемый текст (результат функций локализации) в ключ словаря, сравнивать с помощью == и т. д… В другую сторону это означает такие вещи, как «никогда не показывайте пользователю Enum.ToString() напрямую».

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

Пример:

private void UpdateDisplay(Gender gender)
{
    // Это нельзя локализовать! И регистр выглядит странновато!
    // Не делайте так!
    GenderLabel.Text = gender.ToString();

    // А это хорошо!
    GenderLabel.Text = Loc.GetString($"gender-{gender}");
}

Инвариантные сравнения человекочитаемых строк

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

Свойства

В сеттере свойства значение свойства всегда должно буквально становиться заданным value. Ничего подобного:

public string Name
{
    get => _name;
    private set => _name = Loc.GetString(value);
}

Правильно упорядочивайте члены типа

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

В рамках этого правила автосвойства (например, string FooBar { get; set; }) считаются такими же, как поля, поскольку у них есть внутреннее поле. Неавтосвойства (например, string FooBar => _field.Trim();) таковыми не являются, поэтому их не следует смешивать.

Плохо:

class FooBar
{
    private int _field;

    public void Update() {
        _field *= 2;
        Counter += 1;
    }

    public int Counter { get; set; }
}

Хорошо:

class FooBar
{
    private int _field;
    public int Counter { get; set; }

    public void Update() {
        _field *= 2;
        Counter += 1;
    }

}

Соглашения проекта

Эти соглашения специфичны для Space Station 14. Они могут касаться кода или систем, не относящихся к другим проектам, либо у этих других проектов может быть просто другое мнение о стиле кода.

Структура файла

  1. Начинайте с директив using в верхней части файла.

  2. У всех классов должно быть явно указано пространство имён. Используйте пространства имён уровня файла, например, один namespace Content.Server.Atmos.EntitySystems; перед любыми определениями классов вместо namespace Content.Server.Atmos.EntitySystems { /* class here */ }.

  3. Всегда размещайте все поля и автосвойства перед любыми методами в определении класса.

Методы

Переносы строк в списках параметров/аргументов

Если вы определяете функцию и объявления параметров настолько длинные, что не помещаются в одну строку, разбейте их так, чтобы на каждой строке был один параметр. Небольшая поблажка делается для тесно связанных пар параметров, таких как координаты X/Y и указатель/длина в C API.

Плохо:

public void CopyTo(ISerializationManager serializationManager, SortedDictionary<TKey, TValue> source, ref SortedDictionary<TKey, TValue> target,
    SerializationHookContext hookCtx, ISerializationContext? context = null)

Хорошо:

public void CopyTo(
    ISerializationManager serializationManager,
    SortedDictionary<TKey, TValue> source,
    ref SortedDictionary<TKey, TValue> target,
    SerializationHookContext hookCtx,
    ISerializationContext? context = null)

Константы и CVar

Если у вас есть конкретное значение, например целое число, обычно следует сделать его либо:

  • константой (const), если его никогда не предполагается менять
  • CVar, если его предполагается настраивать

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

Прототипы

Поля данных прототипов

Не кэшируйте прототипы, используйте prototypeManager для их поиска, когда они нужны. Вы можете хранить их по ID. При использовании полей данных, включающих строки ID прототипов, используйте ProtoId. Например, поле данных для списка ID прототипов должно использовать что-то вроде:

[DataField]
public List<ProtoId<ExamplePrototype>> ExampleTypes = new();

Перечисления против прототипов

Использование перечислений для внутриигровых типов категорически не рекомендуется. Всегда следует использовать прототипы вместо перечислений. Пример: внутриигровые «виды» или «типы» инструментов должны использовать прототипы, а не перечисления.

Ресурсы

Звуки

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

Пример кода на C# (нажмите, чтобы развернуть)
[DataField]
public SoundSpecifier Sound = new SoundCollectionSpecifier("MySoundCollection");
Пример прототипа YAML (нажмите, чтобы развернуть)
# Так можно определить коллекцию звуков
- type: soundCollection
  id: MySoundCollection
  files:
  - /Audio/Effects/Cargo/ping.ogg

# И использовать её так
- type: MyComponent
  sound:
    collection: MySoundCollection

Спрайты и текстуры

При указании полей данных спрайта или текстуры используйте SpriteSpecifier.

Пример кода на C# (нажмите, чтобы развернуть)
[DataField]
public SpriteSpecifier Icon = SpriteSpecifier.Invalid;
Пример прототипа YAML (нажмите, чтобы развернуть)
# Можно указать конкретный файл текстуры так; /Textures/ необязателен
- type: MyComponent
  icon: /Textures/path/to/my/texture.png

# /Textures/ необязателен и будет подставлен автоматически, однако убедитесь, что не начинаете путь со слэша, если не указываете его
- type: MyComponent
  icon: path/to/my/texture.png

# Можно указать спрайт rsi так
- type: MyOtherComponent
  icon:
    sprite: /Textures/path/to/my/sprite.rsi
    state: MySpriteState
RSI meta.json (нажмите, чтобы развернуть)
  • Порядок полей должен быть version -> license -> copyright -> size -> states.
  • JSON не должен быть минифицирован и должен следовать обычным рекомендациям по качеству JSON (египетские скобки и т. д.). Все новые JSON-файлы должны иметь отступ в 4 пробела. Существующие файлы следует переводить на отступ в 4 пробела, если вы их изменяете (исправляйте по ходу). Никогда не используйте табуляцию для отступа.

Пример:

{
    "version": 1,
    "license": "CC-BY-SA-3.0",
    "copyright": "Taken from tgstation at commit https://github.com/tgstation/tgstation/commit/547852588166c8e091b441e4e67169e156bb09c1",
    "size": {
        "x": 32,
        "y": 32
    },
    "states": [
        {
            "name": "icon"
        },
        {
            "name": "equipped-BACKPACK",
            "directions": 4
        },
        {
            "name": "inhand-left",
            "directions": 4
        },
        {
            "name": "inhand-right",
            "directions": 4
        }
    ]
}

EntityUid в логах

При использовании EntityUid в админ-логах применяйте метод IEntityManager.ToPrettyString(EntityUid).

Пример админ-лога с сущностями (нажмите, чтобы развернуть)
// Если вы внутри системы сущностей...
_adminLogs.Add(LogType.MyLog, LogImpact.Medium, $"{ToPrettyString(uid)} did something!");

// Если вы не внутри системы сущностей...
_adminLogs.Add(LogType.MyLog, LogImpact.Medium, $"{entityManager.ToPrettyString(uid)} did something!");

Необязательные сущности

Если вам нужно передавать «необязательные» сущности, используйте для этого nullable EntityUid. Никогда не используйте EntityUid.Invalid для обозначения отсутствия EntityUid, всегда используйте null и nullability, чтобы у нас были проверки на этапе компиляции. например, EntityUid? uid

Компоненты

Модификаторы доступа к данным компонентов

Все данные в компонентах должны быть public.

Сеттеры свойств компонентов

Вы не должны иметь сеттеры с какой-либо логикой в свойствах. Вместо этого создайте метод-сеттер в вашей системе сущностей и примените к компоненту атрибут [Friend(...)], чтобы изменить его могла только эта система. Ваш компонент может использовать свойства с логикой в сеттере для интеграции с ViewVariables (пока у нас нет лучшей системы для этого).

Ограничения доступа к компонентам

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

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

Наследование общих компонентов

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

Системы сущностей

Игровая логика

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

Проксирующие методы

Когда возможно, старайтесь использовать проксирующие методы EntitySystem вместо свойства EntityManager.

Примеры (нажмите, чтобы развернуть)
// Без проксирующих методов...
EntityManager.GetComponent<MetaDataComponent>(uid).EntityName;

// С проксирующими методами
Name(uid);

// Без проксирующих методов...
EntityManager.GetComponent<TransformComponent>(uid).Coordinates;

// С проксирующими методами
Transform(uid).Coordinates;

Сигнатура методов публичного API

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

Все соответствующие Entity<T?> и EntityUid должны идти первыми. T? в Entity<T?> обозначает тип компонента, который вам нужен от сущности. Знак вопроса ? должен присутствовать в конце, чтобы пометить тип компонента как nullable. Затем идут любые нужные вам аргументы.

Первым делом в теле метода следует вызвать Resolve для UID сущности и компонентов.

Пример (нажмите, чтобы развернуть)
public void SetCount(Entity<StackComponent?> stack, int count)
{
    // Этот вызов ниже установит «Comp» в правильный экземпляр, если он null.
    // Если все компоненты были разрешены в экземпляр или были не null, возвращается true.
    if(!Resolve(stack, ref stack.Comp))
        return; // Если компонент не найден, по умолчанию будет записана ошибка в лог.

    // Здесь логика!
}

Хелпер Resolve выполняет за вас несколько полезных проверок. В режиме DEBUG он проверяет, действительно ли переданная ссылка на компонент (если не null) принадлежит указанной сущности.

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

Обратите внимание, что у хелпера Resolve также есть перегрузки для разрешения 2, 3 или даже 4 компонентов сразу. Если вы хотите разрешить компоненты для нескольких сущностей или разрешить более 4 компонентов сразу для данной сущности, вам придётся выполнить несколько вызовов Resolve.

Методы расширения

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

Зависимости от других систем

Внутри системы сущностей предпочитайте зависимость от системы вместо разрешения системы через IoCManager. Например, вместо:

var random = IoCManager.Resolve<IRobustRandom>();
random.Prob(0.1f);

Добавьте зависимость системы сущностей:

[Dependency] private readonly IRobustRandom _random = default!;
_random.Prob(0.1f);

События

События-методы против методов систем сущностей

События-методы представляют собой события, которые вы поднимаете, когда хотите выполнить определённое действие. Пример:

// Это изменило бы урон сущности на 10.
RaiseLocalEvent(uid, new ChangeDamageEvent(10));

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

// Это изменило бы урон сущности на 10.
EntitySystem.Get<DamageableSystem>().ChangeDamage(uid, 10);

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

Вы можете использовать события-методы, если они обёрнуты в метод системы сущностей. В примере выше это означало бы, что DamageableSystem.ChangeDamage() внутри себя поднимал бы ChangeDamageEvent, который затем обрабатывался бы любыми подписчиками…

Info

Убедитесь, что от событий отписываются при выключении систем. Проксирующие методы вроде Subs.CVar() или SubscribeLocalEvent уже делают это; обратите внимание, что внутри менеджеров отписываться не нужно, поскольку их время жизни гарантирует, что при их выключении остальная часть клиента/сервера также выключается, из-за чего отписка не требуется.

Именование событий

  • Всегда добавляйте к событиям суффикс Event. Пример: DamagedEvent, AnchorAttemptEvent…

  • Всегда называйте обработчик события так: OnXEvent Пример: OnDamagedEvent, OnAnchorAttemptEvent…

Структурные события по ссылке

События всегда должны быть структурами, а не классами, и всегда должны подниматься по ссылке. Если возможно, они также должны быть readonly, когда это применимо. У них также должен быть атрибут [ByRefEvent].

В практике это будет выглядеть так:

var ev = new MyEvent();
RaiseLocalEvent(ref ev);

События C# против событий EventBus

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

События C# следует использовать для событий вне симуляции, таких как события UI. Однако помните, что от них нужно всегда отписываться!

Асинхронность против событий

Для таких вещей, как DoAfter, всегда используйте события, а не async.

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

UI

UI, определённые на XAML и C#

Всегда следует использовать XAML, а не UI, полностью определённые в коде C#. Расширять существующие определённые на C# UI допустимо, но со временем их следует конвертировать.

Производительность

Методы-итераторы против возврата коллекций

Всегда используйте методы-итераторы вместо создания новой коллекции и возврата её из метода.

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

Запечатанные классы

Ваш класс должен быть помечен как abstract, static, sealed или [Virtual]. Это делается, чтобы случайно не сделать классы наследуемыми, когда они не должны быть таковыми, и может немного повысить производительность при доступе к виртуальным членам или их вызове.

Используйте sealed, если класс не должен наследоваться, [Virtual] для обычного поведения C# (это заглушает предупреждение компилятора), static для классов, которые не нужно создавать, или abstract, если он предназначен для наследования, но не для создания самого по себе.

События вместо обновлений

Где возможно, ваш код в системе всегда должен выполняться в ответ на событие, а не обновляться каждый тик. Ваш код может занимать лишь 0,5 % процессорного времени, но когда так делают 100 систем, это излишне.

Захват переменных

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

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

Пример того, как не надо делать (нажмите, чтобы развернуть)
void DoSomething(EntityUid otherEntity)
{
    // Это ПЛОХО. Будет много выделений в куче.
    var predicate = (EntityUid uid)
        => uid == otherEntity;

    // Этот метод не позволяет передавать пользовательские данные,
    // поэтому мы вынуждены выполнять дорогостоящий захват переменных.
    MethodWithPredicate(predicate);
}

void MethodWithPredicate(Func<EntityUid, bool> predicate)
{
    // Здесь мы что-то делаем с предикатом...
}
Пример того, как надо делать (нажмите, чтобы развернуть)
void DoSomething(EntityUid otherEntity)
{
    // Это хорошо и гораздо производительнее предыдущего примера.
    var predicate = (EntityUid uid, EntityUid otherUid)
        => uid == otherUid;

    // Передаём наши пользовательские данные в этот метод.
    MethodWithPredicate<EntityUid>(predicate, otherEntity);
}

// Этот метод позволяет передавать пользовательские данные в предикат.
void MethodWithPredicate<TState>(Func<EntityUid, TState, bool> predicate, TState state)
{
    // Здесь мы что-то делаем с предикатом, не забывая передавать в него «state»...
}

Дельта-поля

Дельта-поля позволяют передавать по сети только определённые поля компонента вместо всего состояния. Это делается добавлением fieldDeltas: true в ваш атрибут AutoGenerateComponentState:

[RegisterComponent, NetworkedComponent, AutoGenerateComponentState(fieldDeltas: true)]
public sealed partial class MyComponent : Component
{
    [DataField, AutoNetworkedField]
    public bool IsActive;

    [DataField, AutoNetworkedField]
    public int Value;
}

Когда использовать дельта-поля

Дельта-поля отлично подходят, когда:

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

Хорошее эмпирическое правило: если у вас есть 3+ поля и они часто изменяются независимо, рассмотрите дельта-поля. Для компонентов всего с 1-2 полями обычно проще их не использовать.

Пометка полей как грязных

Когда вы изменяете поле и хотите передать по сети только это поле, используйте DirtyField вместо Dirty:

// Вместо этого:
comp.IsActive = true;
Dirty(uid, comp);  // Отправил бы ВСЕ сетевые поля

// Делайте так:
comp.IsActive = true;
DirtyField(uid, comp, nameof(MyComponent.IsActive));  // Отправляет только IsActive

Для компонента с множеством полей, где обычно за раз меняется лишь одно-два, дельта-поля могут снизить сетевой трафик на 80-90 %. Чем больше у вас полей, тем больше вы выиграете от дельта-полей.

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

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

TimeSpan

Использование TimeSpan

Всегда следует использовать TimeSpan вместо float для определения статических промежутков времени, например интервалов. Циклы обновления должны сравниваться с CurTime, а не накапливать frametime.

Обработка приостановленных сущностей

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

AutoGenerateComponentPause and AutoPausedField

Атрибуты [AutoGenerateComponentPause] и [AutoPausedField] работают вместе, чтобы автоматически корректировать поля TimeSpan, когда сущность снимается с паузы:

  • [AutoGenerateComponentPause] применяется к классу компонента и автоматически генерирует код для обработки снятия с паузы.
  • [AutoPausedField] применяется к отдельным полям TimeSpan внутри этого компонента, которые должны корректироваться при снятии сущности с паузы.

Эти атрибуты следует всегда использовать для TimeSpan-свойств DataField, которые изменяются другими системами во время выполнения, например таймеров или перезарядок.

Пример использования (нажмите, чтобы развернуть)
[RegisterComponent, AutoGenerateComponentPause]
public sealed partial class CooldownComponent : Component
{
    [DataField, AutoPausedField]
    public TimeSpan CooldownEnd;

    [DataField, AutoPausedField]
    public TimeSpan? OptionalTimer;
}

TimeOffsetSerializer

TimeOffsetSerializer используется для сериализации значений TimeSpan, которые смещены на текущее игровое время.

  • Он автоматически смещает TimeSpan на текущее игровое время во время сериализации/десериализации
  • Если сущность приостановлена, в качестве точки отсчёта используется время, в которое сущность была приостановлена
  • Он предотвращает непреднамеренное сохранение смещений времени на карты во время маппинга (прототипы всегда сериализуются как ноль)

Как и в случае с AutoPausedField, TimeOffsetSerializer всегда следует использовать для изменяемых во время выполнения полей TimeSpan, которые представляют абсолютное время, а не длительности.

Пример использования (нажмите, чтобы развернуть)
[DataField(customTypeSerializer: typeof(TimeOffsetSerializer))]
public TimeSpan NextActivationTime;

Именование

Общие типы

Типы следует снабжать префиксами Client и Server только если они находятся в клиентской/серверной сборках.

Пример:

  • Если FooComponent существует только в shared, префикс не нужен.
  • Если BarComponent существует в shared, server и client, клиентский/серверный типы следует снабдить префиксами: ClientBarComponent / ServerBarComponent.

Физика

Закрепление

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

Соглашения YAML

  • Каждый - type компонента должен идти вместе без пустых строк, разделяющих их
  • Разделяйте прототипы одной пустой строкой.
  • Поля name: и description: никогда не должны содержать кавычки, если только пунктуация в имени/описании не требует их использования, тогда используйте ‘’. Например:
  name: 'Spessman's Smokes packet'
  description: 'A label on the packaging reads, 'Wouldn't a slow death make a change?''
  • Не указывайте текстуры в абстрактных прототипах/родителях.
  • Первый блок прототипа следует объявлять в таком порядке: type > abstract > parent > id > categories > name > suffix > description > components.
  • Используйте встроенные списки для категорий и обычные списки для всего остального:
    - type: entity
      parent: [ PartHuman, BaseHead ] # Встроенный список
      id: Headhuman
      components:
      - type: Tag
        tags: # Обычный список
        - Head
    
  • Новые компоненты не должны иметь отступ при добавлении в раздел components:. Так
    components:
    - type: Sprite
      state:
    
    Не так
    components:
      - type: Sprite
        state:
    
  • То же правило применяется к любому другому списку или словарю, например:
    - type: Tag
      tags:
      - HighRiskItem # Правильный отступ
    
    - type: Tag
      tags:
        - HighRiskItem # Неправильный отступ
    
  • Когда это имеет смысл, размещайте более обобщённые/движковые компоненты ближе к началу списка компонентов, а более специфичные компоненты ближе к концу списка. Например,
    components:
    - type: Sprite # Специфично для движка
    - type: Physics
    - type: Anchorable # Content, но обобщённый
    - type: Emitter # Компонент для конкретного типа предмета
    

Именование в YAML и полях данных

PascalCase используется для ID и имён компонентов. Всё остальное, даже имена типов прототипов, использует camelCase. prefix.Something НИКОГДА не должен использоваться для ID.

Сущности

Пожалуйста, структурируйте сущности с компонентами так, как показано ниже, для лучшей читаемости YAML:

- type: entity
  abstract: true # удалите эту строку, если не abstract
  parent: <nameofparent>
  id:
  name:
  components:
  <rest of file>

Суффиксы прототипов сущностей

Используйте suffix в прототипах; это суффикс, отображаемый только в меню спавна, который позволяет различать прототипы, не изменяя само имя прототипа. Использовать его можно так: entityprototypesuffixes1.png

И это даёт такой результат: entityprototypesuffixes2.png

Локализация

Каждая строка, отображаемая игроку, всегда должна быть локализована.

Именование ID локализации

  • ID локализации всегда в kebab-case и никогда не должны содержать заглавных букв.
  • ID локализации должны быть как можно более конкретными, чтобы не конфликтовать с другими ID. Так
    antag-traitor-user-was-traitor-message = ...
    
    Не так
    traitor-message = ...
    
    

Внутри симуляции или вне симуляции

Warning

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

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

Например, следующие вещи находятся «внутри» симуляции:

  • Практически всё, что касается сущностей: взаимодействия, физика, атмос, и т. д.
  • IC-чат
  • Состояние раунда (лобби, в игре, после игры)

Следующие примеры находятся «вне» симуляции:

  • OOC-чат
  • Админ-помощь
  • Админ-голосования
  • Практически всё, что обращается к внешнему сервису, например к базе данных или вебхуку Discord

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

Мысленный эксперимент для этого: «должна ли эта логика перестать работать, если игра будет приостановлена админом». Если бы такая кнопка паузы существовала, мы хотели бы полностью остановить игровую логику (время не шло бы, никто не мог бы двигаться и т. д.), но при этом хотели бы, чтобы люди могли подключаться к серверу, общаться в OOC-чате, спрашивать админа, почему игра всё ещё на паузе, и так далее.

Info

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

Время в симуляции может ускоряться или замедляться относительно «реального времени» в зависимости от настроек сервера или проблем с производительностью. На клиенте симуляция постоянно совершает путешествие во времени в рамках сетевого предсказания. Симуляция фактически не существует на клиенте, пока он не подключён к серверу!

Вот некоторые различия между тем, как следует писать код внутри симуляции и вне неё:

Что вы хотите сделатьвнутри симуляциивне симуляции
«Место по умолчанию» для синглтон-кода.Создайте EntitySystemИспользуйте менеджер: создайте новый класс, зарегистрируйте его в IoC и вызывайте из EntryPoint или подобного.
Проверить прошедшее времяIGameTiming.CurTimeIGameTiming.RealTime, (R)Stopwatch, DateTime и т. д.
Отправлять пользовательские сетевые сообщенияСетевые события сущностейПользовательский NetMessage

Subpages