#РАЗРАБОТКА: НА ПРИМЕРАХ
1 messages · Page 1 of 1 (latest)
ECS: Сущности, Компоненты, Системы
Сущность (Entity) — любой объект в игре. Игрок, банан, дубинка — всё это сущности, представленные уникальным целым числом. Сами по себе сущности не имеют никакого поведения.
Компонент (Component) — хранит данные и помечает сущность как обладающую определённым поведением. Например, NukeComponent означает, что эта сущность ведёт себя как ядерная бомба и хранит данные о таймере.
Система (EntitySystem) — содержит логику для компонентов. На весь сервер существует один NukeSystem, который обрабатывает все сущности с NukeComponent. Системы реагируют на события или обновляются каждый тик.
Прототипы
Прототипы — это «пресеты» сущностей, аналог префабов в Unity. Они определяют, какие компоненты есть у сущности и какие данные в них хранятся. Записываются в YAML и размещаются в Resources/Prototypes.
- 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: EmitSoundOnUse
sound: /Audio/Items/skub.ogg
- type: UseDelay
delay: 2.0
Спаунить сущности в игре можно через F5 (Entity Spawn Panel).
Пример: Велосипедный гудок с нуля
Разбираем создание предмета, который издаёт звук при использовании.
1. Прототип
Создаём файл clown_horn.yml в Resources/Prototypes/Entities/Objects/Fun/:
- 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
2. Компонент
Создаём Content.Server/Sound/PlaySoundOnUseComponent.cs. Имя компонента в YAML генерируется автоматически — убирается суффикс Component:
namespace Content.Server.Sound;
[RegisterComponent]
public sealed partial class PlaySoundOnUseComponent : Component
{
[DataField]
public string Sound = string.Empty;
}
3. Система
Создаём Content.Server/Sound/PlaySoundOnUseSystem.cs:
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)
{
_audio.PlayPvs(ent.Comp.Sound, ent.Owner);
}
}
PlayPvs автоматически обрабатывает дистанционную фильтрацию — игроки вне зоны слышимости не услышат звук.
Динамические спрайты и AppearanceComponent
Когда спрайт должен меняться в зависимости от состояния сущности (открыт/закрыт, есть предмет/нет), используется система AppearanceComponent + VisualizerSystem.
Сервер отправляет только простые данные (булево, число, enum), а клиент самостоятельно обновляет спрайт на их основе. Это экономит трафик и разгружает сервер.
На сервере — устанавливаем данные:
_appearanceSystem.SetData(ent, ItemCabinetVisuals.IsOpen, ent.Comp1.Opened);
На клиенте — читаем и применяем:
public sealed class ItemCabinetSystem : VisualizerSystem<ItemCabinetVisualsComponent>
{
protected override void OnAppearanceChange(Entity<ItemCabinetVisualsComponent> ent, ref AppearanceChangeEvent args)
{
if (TryComp(ent, out SpriteComponent? sprite)
&& args.Component.TryGetData(ItemCabinetVisuals.IsOpen, out bool isOpen)
&& args.Component.TryGetData(ItemCabinetVisuals.ContainsItem, out bool contains))
{
sprite.LayerSetState(ItemCabinetVisualLayers.Door, isOpen ? ent.Comp.OpenState : ent.Comp.ClosedState);
sprite.LayerSetVisible(ItemCabinetVisualLayers.ContainsItem, contains);
}
}
}
Если логика проста — только переключение состояний спрайта — используй GenericVisualizerSystem вместо написания своего:
- type: Appearance
- type: GenericVisualizer
visuals:
enum.ItemCabinetVisuals.IsOpen:
enum.ItemCabinetVisualLayers.Door:
True: { state: open }
False: { state: closed }
Спрайт-слои
Сложные спрайты используют слои. Дверь, например, имеет отдельные слои для базового состояния, подсветки, болтов, сварки:
- type: Sprite
sprite: Structures/Doors/Airlocks/Standard/basic.rsi
layers:
- state: closed
map: ["enum.DoorVisualLayers.Base"]
- state: bolted_unlit
shader: unshaded
map: ["enum.DoorVisualLayers.BaseBolted"]
- state: welded
map: ["enum.DoorVisualLayers.BaseWelded"]
Слои с shader: unshaded не затрагиваются освещением — создают иллюзию собственного свечения.
Сетевое взаимодействие
Синхронизация компонентов
Для автоматической синхронизации полей компонента используй атрибуты:
[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
public sealed partial class IdCardComponent : Component
{
[DataField, AutoNetworkedField]
public string? FullName;
[DataField, AutoNetworkedField]
public string? JobTitle;
}
При изменении поля вызови Dirty(uid, comp) — сервер отправит обновление клиенту. Для компонентов с множеством независимо меняющихся полей используй DirtyField вместо Dirty, чтобы слать только изменившееся поле.
Сетевые события
Для передачи произвольных данных между сервером и клиентом используются сетевые события:
[Serializable, NetSerializable]
public sealed class BwoinkTextMessage : EntityEventArgs
{
public NetUserId ChannelId { get; }
public string Text { get; }
// ...
}
Важно: всегда валидируй данные, полученные от клиента на сервере. Клиент может прислать что угодно.
Предсказание (Prediction)
Предсказание позволяет клиенту реагировать на ввод мгновенно, не дожидаясь ответа сервера. Делает игру отзывчивой при высоком пинге.
Чеклист для реализации предсказания:
- Переноси компоненты и системы из
Content.ServerвContent.Shared - Добавляй
[NetworkedComponent]и[AutoGenerateComponentState]к компонентам - Помечай изменяемые поля атрибутом
[AutoNetworkedField] - Вызывай
Dirty(ent)при каждом изменении поля - Используй предсказываемые методы:
PlayPredicted,PopupPredicted,PredictedSpawnAtPosition - Не используй
RobustRandomв общем коде — сервер и клиент получат разные результаты
Тестирование: команда sudo cvar net.fakelagmin 0.5 увеличивает искусственную задержку. Если предсказание работает, задержка не ощущается.
Локализация (Fluent)
Весь текст, видимый игроку, хранится в .ftl-файлах в Resources/Locale/<код языка>/. В коде никакого захардкоженного текста быть не должно.
Простое сообщение:
comp-stack-already-full = Stack is already full.
Loc.GetString("comp-stack-already-full")
Сообщение с переменной:
traitor-user-was-a-traitor = {$user} was a traitor.
Loc.GetString("traitor-user-was-a-traitor", ("user", "Bob"))
// → "Bob was a traitor."
Род, склонение, местоимения — через встроенные функции:
humanoid-character-profile-summary =
This is {$ent}. {SUBJECT($ent)} {CONJUGATE-BE($ent)} {$age} years old.
Функции: SUBJECT, OBJECT, POSS-ADJ, REFLEXIVE, THE, INDEFINITE, CAPITALIZE, CONJUGATE-BE, CONJUGATE-HAVE.
Локализация прототипов — через атрибуты Fluent:
- type: entity
id: RedOxygenTank
# name и description не указываем в YAML
ent-RedOxygenTank = oxygen tank
.desc = A tank of oxygen. This one is red.
Правила: отступы только пробелами (не табами), ID всегда в kebab-case, префикс по контексту (comp-stack-, traitor- и т.д.).
UI
Базовые правила
Всегда используй FancyWindow вместо DefaultWindow:
<ui:FancyWindow xmlns="https://spacestation14.io"
xmlns:ui="clr-namespace:Content.Client.UserInterface">
<!-- содержимое -->
</ui:FancyWindow>
Используй стиль-классы из StyleClass.cs вместо хардкода цветов. Несколько классов на одном элементе:
<Button Text="delete">
<Button.StyleClasses>
<sys:String>ButtonSmall</sys:String>
<sys:String>negative</sys:String>
</Button.StyleClasses>
</Button>
Для статусных цветов (красный/жёлтый/зелёный):
Palettes.Status.GetStatusColor(0.0f); // красный
Palettes.Status.GetStatusColor(0.5f); // жёлтый
Palettes.Status.GetStatusColor(1.0f); // зелёный
Кастомная кнопка с hover-эффектом
Иногда стандартных кнопок не хватает. Вот паттерн для кнопки с полностью кастомной визуализацией при наведении.
Сначала создаём класс-обёртку над Button, которая сообщает об изменении режима отрисовки:
namespace Content.Client.MyNamespace;
public sealed class DrawButton : Button
{
public event Action? OnDrawModeChanged;
protected override void DrawModeChanged()
{
OnDrawModeChanged?.Invoke();
}
}
XAML для кнопки — невидимая DrawButton поверх PanelContainer:
<Control xmlns="https://spacestation14.io"
xmlns:graphics="clr-namespace:Robust.Client.Graphics;assembly=Robust.Client"
xmlns:local="clr-namespace:Content.Client.MyNamespace">
<BoxContainer Orientation="Vertical" SetSize="80 80">
<PanelContainer VerticalExpand="True" HorizontalExpand="True" Name="Panel">
<PanelContainer.PanelOverride>
<graphics:StyleBoxFlat BackgroundColor="#162031"
BorderColor="#4972A1"
BorderThickness="2"/>
</PanelContainer.PanelOverride>
<local:DrawButton Name="Button"
Access="Public"
ModulateSelfOverride="#00000000"
HorizontalExpand="True"/>
<!-- сюда добавляем содержимое: текст, иконку и т.д. -->
</PanelContainer>
</BoxContainer>
</Control>
Code-behind — логика смены цвета при наведении:
[GenerateTypedNameReferences]
public sealed partial class MyFancyButton : LayoutContainer
{
public Action<BaseButton.ButtonEventArgs>? OnPressed;
public static readonly Color DefaultColor = Color.FromHex("#141F2F");
public static readonly Color DefaultBorderColor = Color.FromHex("#4972A1");
public static readonly Color DefaultHoveredColor = Color.FromHex("#4972A1");
private static readonly Color DisabledColor = Color.FromHex("#999999");
public Color Color = DefaultColor;
public Color BorderColor = DefaultBorderColor;
public Color HoveredColor = DefaultHoveredColor;
public MyFancyButton()
{
RobustXamlLoader.Load(this);
Button.OnPressed += Pressed;
Button.OnDrawModeChanged += UpdateColor;
UpdateColor();
}
private void UpdateColor()
{
var panel = (StyleBoxFlat) Panel.PanelOverride!;
panel.BackgroundColor = Button.Disabled ? DisabledColor
: Button.IsHovered ? HoveredColor : Color;
panel.BorderColor = BorderColor;
}
protected override void ExitedTree()
{
base.ExitedTree();
Button.OnPressed -= Pressed;
}
private void Pressed(BaseButton.ButtonEventArgs args)
{
OnPressed?.Invoke(args);
}
}
тут будет больше если @plucky violet дополнит
уээ
алибаба