Добавление простого велосипедного клаксона

Неактуально

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

В этом руководстве на примере реализации клоунского клаксона с нуля рассматривается ECS (система «сущность-компонент») и несколько других ключевых тем в кодовой базе SS14. Вы можете попробовать повторить шаги сами или просто читать дальше.

Сущности, компоненты и системы

Хотя Space Station 14 написана на C# — объектно-ориентированном языке программирования, — для представления игровых предметов она использует другую модель данных. Эта модель данных называется системой «сущность-компонент» (ECS). (Почему мы так делаем? См. ECS)

Сущности

Каждый игровой предмет представлен сущностью. Игроки, бананы, дубинки-шокеры — всё это представлено сущностями. Сущность представляется целым числом. Никакие две сущности не имеют одинакового целочисленного представления.

Сами по себе сущности лишь отличают один предмет от другого. Без каких-либо компонентов у сущности нет поведения.

Компоненты

Компоненты выполняют две основные функции:

  1. Помечают конкретные сущности как обладающие конкретным поведением. Например, в одной конкретной игре сущность, представленная числом 37629, содержит NukeComponent и ActivatableUIComponent. Это значит, что данная сущность ведёт себя как ядерная боеголовка, а также имеет пользовательский интерфейс, который можно вызвать, активировав её.

  2. Хранят данные, необходимые для обработки её поведения. Например, NukeComponent может иметь поле данных Timer, представляющее, сколько времени осталось до детонации боеголовки.

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

Системы

Система сущностей (часто сокращается до «система») содержит логику, реализующую поведение для конкретных компонентов. Хотя в одной игре может быть несколько сущностей с NukeComponent, NukeSystem только одна. Единственная NukeSystem отвечает за обработку всех сущностей с NukeComponent.

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

В качестве другого примера рассмотрим FoodComponent. Программист может создать EatingSystem для обработки поедания еды. EatingSystem слушает событие OnUseInHand — всякий раз, когда OnUseInHand услышано/вызвано, EatingSystem проверяет, есть ли FoodComponent в объекте, который был использован. Если есть, он уменьшает значение nutritionLeft и проигрывает звук чавканья.

Вот и вся суть ECS. Если вам интересно узнать о ней больше, загляните в Ваш разум на ECS. Подход ECS действительно мощен и позволяет нам избегать спагетти-кода, несмотря на сложность SS14.

Info

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

Как мне создать сущность и дать ей компоненты?

SS14 использует систему, которую мы называем прототипами. По сути это «заготовки сущностей». Они похожи на префабы в Unity или на подтип /obj или /mob в BYOND.

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

Пример показан ниже:

- type: entity
  parent: BaseItem
  id: Skub
  name: skub
  description: Skub is the fifth Chaos God.
  components:
  - type: Sprite
    sprite: Objects/Misc/skub.rsi
    state: icon
  - type: Item
  - type: ItemCooldown
  - type: EmitSoundOnUse
    sound: /Audio/Items/skub.ogg
  - type: UseDelay
    delay: 2.0

Это написано на YAML — языке данных, похожем на JSON, — и находится в папке Resources/Prototypes/Entities/Objects/Fun/skub.yml. Все прототипы должны находиться в папке Resources/Prototypes и должны быть организованы в подходящую папку.

Если вам нужны дополнительные подсказки по YAML, ознакомьтесь с Ускоренным курсом по YAML и Сериализацией.

Показанный прототип сущности — «Skub», который в игре выглядит так:

skubexample.png

Как видно из YAML, у неё много компонентов, включая EmitSoundOnUse и ItemCooldown. Именно программисты решают, какие данные хранят компоненты и как системы придают им поведение.

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

Ладно, а теперь я хочу погудеть!

Ваша цель — сделать клоунский клаксон, который гудит при использовании. Для этого нам нужен компонент на сущности со звуком для воспроизведения и система, которая проигрывает этот звук после использования в руке (по клику или активации клавишей Z).

Info

Обычно вам стоило бы поискать по кодовой базе и спросить других программистов, не существует ли уже компонента/системы, делающей это. В данном случае EmitSoundOnUse действительно существует в основной кодовой базе SS14. Но ради этого руководства мы представим, что его нет, и попробуем реализовать его сами!

Для начала давайте сделаем простой прототип клоунского клаксона. Я создам новый файл с именем clown_horn.yml и добавлю его в папку Resources\Prototypes\Entities\Objects.

clownhornexample1.png

Позже, возможно, стоит переместить его в папку «Fun», но организация — на ваше усмотрение и усмотрение вашей кодовой базы!

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

- type: entity
  name: clown horn
  parent: BaseItem
  id: ClownHorn
  description: It goes honk honk!
  components:
  - type: Sprite
    sprite: Objects/Fun/bikehorn.rsi
    state: icon

Здесь у нас базовая сущность с единственным компонентом: SpriteComponent. Ознакомьтесь со спецификацией RSI, если вы не знакомы с системой RSI, но суть в том, что у SpriteComponent есть два поля: путь к RSI относительно Resources/Textures (в данном случае папка называется bikehorn.rsi) и состояние иконки.

Стоит отметить, что прототипы поддерживают наследование. В данном случае BaseItem — наш родитель, содержащий множество компонентов, универсальных для всех предметов. Таким образом, у нашего клоунского клаксона тоже будут эти компоненты: базовые компоненты вроде Item, Pullable и Physics. Родители вовсе не обязательны, но в определённых случаях они полезны, как здесь.

Теперь давайте скомпилируем и посмотрим на наш предмет в игре:

clownhornexample2.png

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

Создание нашего компонента

Чтобы сделать наш компонент, нам нужно создать новый класс, назовём его PlaySoundOnUseComponent. Но погодите-ка….

componentcreation.png

Куда его поместить? Чтобы ответить на этот вопрос, нужно мыслить широко. Нужно подумать о клиенте и сервере.

Парадигма клиент-сервер

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

  • СЕРВЕР и КЛИЕНТ выполняются РАЗДЕЛЬНО.
  • Сервер должен обрабатывать большую часть логики, чтобы предотвращать эксплойты. Всё, что находится на клиенте, может быть изменено злонамеренным пользователем.

С учётом этого логика нашего клоунского клаксона должна выглядеть так:

  • Клиент отправляет серверу «Я использую этот предмет».
  • Сервер получает это, проверяет, имеет ли это смысл, и отправляет «проиграть гудок» всем клиентам в радиусе.
  • Клиент получает это и проигрывает «гудок».

Звучит довольно сложно для реализации с нуля. К счастью, у нас есть готовый код, который помогает! А именно событие UseInHandEvent, которое поднимается на сервере при использовании предмета, и функция SoundSystem.Play(), которая проигрывает звук клиентам в радиусе.

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

Базовая реализация компонента

Warning

В кодовой базе Space Station 14 и компоненты, и системы сущностей (а также другие классы) располагаются в папках непосредственно внутри проектов Content.Server, Content.Shared или Content.Client. Есть папки для Atmos, Botany, Research, Storage и многого другого. Если подходящей папки нет, создайте её! Никогда не помещайте файлы прямо в верхний каталог проекта.

В проекте Content.Server есть папка Sound. В ней находится метко названная папка Components. Похоже, это хорошее место для нашего нового компонента (и на самом деле именно там находится настоящий EmitSoundOnTriggerComponent). Назовём нашу версию PlaySoundOnUseComponent. Примечание: если вы просто скопируете этот код, он может не заработать, так как потребуется импортировать различные классы. Ваша IDE может сделать это за вас.

Теперь давайте сделаем самый простой компонент из возможных:

// Content.Server/Sound/PlaySoundOnUseComponent.cs

namespace Content.Server.Sound;

[RegisterComponent]
public sealed partial class PlaySoundOnUseComponent : Component
{
}

Все компоненты должны наследоваться от класса Component. Если вы хотите, чтобы ваш компонент читался в YAML, нужно добавить [RegisterComponent] над классом. Кроме того, все компоненты должны быть помечены sealed и partial по причинам, связанным с движком. Не стоит слишком беспокоиться о том, что это значит.

В нашем прототипе выше, как вы, возможно, помните, мы добавили Sprite, а не SpriteComponent, в прототип ClownHorn. Это потому, что «имена» компонентов автоматически генерируются из имени класса. В данном случае имя нашего компонента — PlaySoundOnUse, которое получается простым удалением Component из имени класса.

Теперь давайте добавим PlaySoundOnUse в наш прототип.

Info

При использовании компонентов в yaml прототипа необходимо опускать часть Component в конце имени класса. Так, PlaySoundOnUseComponent будет распознан как PlaySoundOnUse в списке components: в определении yaml.

- type: entity
  name: clown horn
  parent: BaseItem
  id: ClownHorn
  description: It goes honk honk!
  components:
  - type: Sprite
    sprite: Objects/Fun/bikehorn.rsi
    state: icon
  - type: PlaySoundOnUse

Ну, это скучно; наш компонент не только не имеет никаких данных, но ещё и ничего не делает!

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

В нашем случае, вероятно, нам нужно поле с именем sound в компоненте, которое хранит путь к звуку для воспроизведения при активации сущности. Сделать это довольно просто:

// Content.Server/Sound/PlaySoundOnUseComponent.cs

namespace Content.Server.Sound;

[RegisterComponent]
public sealed partial class PlaySoundOnUseComponent : Component
{
    [DataField]
    public string Sound = string.Empty;
}

Всё, что нужно сделать для создания поля, изменяемого в YAML, — добавить атрибут [DataField], который содержит имя поля, и задать ему значение по умолчанию, в данном случае string.Empty. Теперь мы можем добавить наш звук в прототип велосипедного клаксона:

- type: entity
  name: clown horn
  parent: BaseItem
  id: ClownHorn
  description: It goes honk honk!
  components:
  - type: Sprite
    sprite: Objects/Fun/bikehorn.rsi
    state: icon
  - type: PlaySoundOnUse
    sound: /Audio/Items/bikehorn.ogg

Вот теперь дело пошло! Стоит отметить, что путь здесь относителен каталога Resources (что SoundSystem всегда подразумевает), а также что мы предполагаем, будто файл Resources/Audio/Items/bikehorn.ogg реален. Если проверить — он существует! Но если нужного вам звука нет, вы всегда можете добавить его сами куда-нибудь в папку Audio.

Создание нашей системы сущностей

Давайте наконец придадим нашему велосипедному клаксону изюминку,.. заставив его действительно гудеть. Как говорилось ранее, нам нужна EntitySystem, которая подключается к UseInHandEvent и вызывает оттуда некоторый код. Создадим нашу систему сущностей PlaySoundOnUseSystem в той же папке Content.Server/Sound:

// Content.Server/Sound/PlaySoundOnUseSystem.cs

namespace Content.Server.Sound;
    
public sealed class PlaySoundOnUseSystem : EntitySystem
{

}

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

Чтобы подписаться на поднимаемое событие, нам нужно переопределить метод Initialize системы; этот метод вызывается при создании системы сущностей.

В этом методе мы добавим вызов SubscribeLocalEvent, а детали я объясню после.

// Content.Server/Sound/PlaySoundOnUseSystem.cs

namespace Content.Server.Sound;

public sealed class PlaySoundOnUseSystem : EntitySystem
{
    public override void Initialize()
    {
        SubscribeLocalEvent<PlaySoundOnUseComponent, UseInHandEvent>(OnUseInHand);
    }
}

В этом вызове метода много всего происходит! По сути, мы говорим игре:

«Всякий раз, когда на сущности с компонентом PlaySoundOnUse поднимается UseInHandEvent, я хочу, чтобы ты вызвал мой метод OnUseInHand.»

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

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

Если вы используете IDE, она может позволить вам автоматически создать этот метод с помощью Alt+Enter.

Вот как теперь будет выглядеть наш класс с новым методом:

namespace Content.Server.Sound;

public sealed class PlaySoundOnUseSystem : EntitySystem
{
    [Dependency] private readonly SharedAudioSystem _audio = default!;
    
    public override void Initialize()
    {
        SubscribeLocalEvent<PlaySoundOnUseComponent, UseInHandEvent>(OnUseInHand);
    }

    private void OnUseInHand(Entity<PlaySoundOnUseComponent> ent, ref UseInHandEvent args)
    {

    }
}

Мы почти у цели. Теперь метод OnUseInHand будет вызываться при активации предмета, и там мы сможем проиграть наш звук.

Кроме того, мы добавили в класс [Dependency] private readonly SharedAudioSystem. Это позволит нам в дальнейшем проигрывать звук современным способом (вместо использования устаревшего SoundSystem.Play).

private void OnUseInHand(Entity<PlaySoundOnUseComponent> ent, ref UseInHandEvent args)
{
    _audio.PlayPvs(ent.Comp.Sound, ent.Owner);
}

Метод PlayPvs удобен для проигрывания звуков. У него два аргумента:

  1. Звук для воспроизведения.

В данном случае мы просто передаём ему наше поле sound из PlaySoundOnUseComponent.

  1. Сущность-источник

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

Если вы скомпилируете игру и заспавните наш велосипедный клаксон через F5 — меню спавна сущностей, вы можете попробовать активировать его в руке и — невероятно! Он правильно проигрывает звук! Надеемся! Если нет, возможно, вы что-то напутали в YAML или пропустили метод в системе сущностей.

Кроме того, PlayPvs автоматически управляет фильтрацией по расстоянию, так что об этом беспокоиться не нужно.

На этом всё

На этом руководство завершено! Если вы хотите продолжить эксперименты с вашим новым клоунским клаксоном, вот несколько идей:

  • Попробуйте реализовать клоунский клаксон с помощью существующих компонентов. Можете обратиться к skub.yml выше на этой странице
  • Добавьте задержку между кликами, добавив ItemCooldown в прототип и поднимая RefreshItemCooldownEvent.
  • Настройте громкость/варьирование проигрываемого звука (см. аргумент audioParams функции PlayPvs()).
  • Сделайте так, чтобы звук проигрывался и при наступании на велосипедный клаксон
    • Это довольно сложно и требует добавления множества новых данных! Посмотрите на осколки стекла для примера.
  • Сделайте так, чтобы велосипедный клаксон наносил урон при атаке, используя MeleeWeaponComponent
  • Сделайте велосипедный клаксон съедобным, используя FoodComponent и SolutionContainerComponent
  • Добавьте поддержку проигрывания случайного звука из SoundCollection или SoundSpecifier вместо одного звука (настоящий EmitSoundOnUse делает это, если вам нужны подсказки)
  • Загляните в код взрывов и дайте ему 5% шанс взорваться при каждом гудке!

Мир — ваш донк-покет, а у вас есть раскалённый огонь, готовый его запечь!

Subpages