Теория ECS в Hytale
В этом руководстве вы узнаете об основах мощной системы ECS Hytale, а также научитесь создавать собственные компоненты, системы и взаимодействовать с другими системами для создания игровой логики.
Хранилище (Store)
Класс Store является ядром системы ECS в Hytale, он отвечает за хранение сущностей. Если вам когда-либо понадобится получить доступ к сущности, вам в первую очередь потребуется получить доступ к хранилищу. Он использует концепцию, называемую архетипами, где данные группируются вместе в чанки. Например, если у нас есть 100 трорков, они будут объединены в чанки вместе со своими компонентами, чтобы находиться рядом в памяти и их было быстрее получать.
EntityStore
При просмотре серверного кода Hytale вы увидите, что в большинстве случаев Store имеет тип EntityStore. Это название может вводить в заблуждение, так как создаётся впечатление, что это Store (Хранилище) сущностей.
Но разве мы не говорили, что Store сам по себе уже хранит сущности? Класс EntityStore реализует WorldProvider, что означает, что EntityStore отвечает за доступ к конкретному миру (World) Hytale. Он поддерживает внутренние словари entitiesByUuid и networkIdToRef, что позволяет найти конкретную сущность по её постоянному ID или по сетевому ID.
У каждой сущности есть UUIDComponent, а также NetworkId, которые используются EntityStore для поиска сущностей внутри Store.
ChunkStore
Ещё один тип Store, с которым вы можете столкнуться, - это ChunkStore. Он отвечает за хранение всех компонентов, связанных с блоками внутри World. Вы можете получить WorldChunk - это ваши общие компоненты чанков.
Компонент WorldChunk содержит EntityChunk, который хранит все сущности, находящиеся внутри чанка, а также их ссылку на EntityStore. Он также содержит BlockChunk, который состоит из BlockSection. Есть ещё множество компонентов, составляющих общую систему мира и чанков, но на данный момент это базовое понимание того, что такое ChunkStore. Вы можете использовать его для получения данных о чанках и их блоках, а также о сущностях в конкретном чанке, а также для создания систем блоков и чанков.
Holder
Holder - это по сути чертёж (шаблон) сущности. Прежде чем сущность появится в Store (и, соответственно, в мире), она существует как Holder. Он собирает и хранит все необходимые компоненты (данные). Можно сравнить это с корзиной для покупок. Вы берёте все нужные компоненты, и когда всё собрано, "оформляете покупку" в Store: он принимает ваш "корзину", создаёт корректный ID сущности и выдаёт вам "чек" (Ref).
Давайте рассмотрим пример: инициализация игроков. В Universe метод addPlayer демонстрирует это идеально.
Когда игрок подключается, мы не сразу бросаем его в ECS. Сначала мы создаем их данные в Holder.
Обратите внимание, что метод PlayerStorage#load, который загружает данные игрока с диска, возвращает CompletableFuture<Holder<EntityStore>>.
Это означает, что метод выполняется асинхронно, и в будущем будет содержаться Holder для сущности в EntityStore.
Просто откройте класс Universe, найдите метод addPlayer и прочитайте его от начала до конца. Поверьте, это очень поможет: вы увидите реальный процесс создания сущности и через что ей приходится пройти. В конце Universe вызывает world#addPlayer, который (после отправки события) вызывает метод...
Ref<EntityStore> ref = playerRefComponent.addToStore(store);А PlayerRef#addToStore содержит следующее:
store.addEntity(this.holder, AddReason.LOAD);Ref (Reference; Ссылка)
Тем, кто знаком с такими языками, как C++, по названию уже, вероятно, понятно, что это за класс. Однако Ref - это безопасный "дескриптор" или указатель на сущность. Вы НИКОГДА не должны хранить прямую ссылку на объект сущности - вместо этого используйте Ref. Он отслеживает, жива ли сущность. Если вызвать validate() у Ref на сущность, которая была удалена, будет выброшено исключение.
Компоненты игрока
В Hytale "Игрок" - это не просто один объект. Это одна сущность, состоящая из множества специализированных компонентов. Понимание разницы между Player и PlayerRef имеет решающее значение для моддинга.
PlayerRef
Несмотря на свое название, PlayerRef является компонентом, а не дескриптором. Он представляет подключение и идентичность игрока. Это специальный компонент, который остаётся активным, пока игрок подключён к серверу, даже если он переключается между мирами. Ключевые данные, которые он хранит: имя игрока, UUID, язык и обработчик пакетов.
Player
Компонент Player представляет физическое присутствие игрока. Он существует только тогда, когда игрок действительно появляется в мире. Обеспечивая доступ к данным, относящимся к игровому процессу, этот компонент отличается в зависимости от мира.
Чтобы взаимодействовать с сущностью, вы используете Store, чтобы получить её компоненты по ComponentType. Поскольку Hytale использует раздельную (decoupled) систему, вы не вызываете entity.getHealth(). Вместо этого вы запрашиваете у Store данные о хп, связанные с Ref этой сущности.
@Override
protected void execute(@Nonnull CommandContext commandContext, @Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef playerRef, @Nonnull World world) {
Player player = store.getComponent(ref, Player.getComponentType());
UUIDComponent component = store.getComponent(ref, UUIDComponent.getComponentType());
TransformComponent transform = store.getComponent(ref, TransformComponent.getComponentType());
player.sendMessage(Message.raw("UUIDComponent : " + component.getUuid()));
player.sendMessage(Message.raw("Transform : " + transform.getPosition()));
}Здесь мы используем Store<EntityStore>, чтобы получить компонент Player через Ref<EntityStore>. То же самое можно сделать и с другими компонентами, например UUIDComponent или TransformComponent, чтобы получить трансформ сущности - позицию и положение (поворот).
Components (Компоненты)
Компоненты - это чистые контейнеры данных. Они сохраняют состояние, но не содержат логики. В Hytale компоненты должны реализовывать Component<EntityStore> и представлять метод clone, чтобы ECS мог копировать их при необходимости.
public class PoisonComponent implements Component<EntityStore> {
private float damagePerTick;
private float tickInterval;
private int remainingTicks;
private float elapsedTime;
public PoisonComponent() {
this(5f, 1.0f, 10);
}
public PoisonComponent(float damagePerTick, float tickInterval, int totalTicks) {
this.damagePerTick = damagePerTick;
this.tickInterval = tickInterval;
this.remainingTicks = totalTicks;
this.elapsedTime = 0f;
}
public PoisonComponent(PoisonComponent other) {
this.damagePerTick = other.damagePerTick;
this.tickInterval = other.tickInterval;
this.remainingTicks = other.remainingTicks;
this.elapsedTime = other.elapsedTime;
}
@Nullable
@Override
public Component<EntityStore> clone() {
return new PoisonComponent(this);
}
public float getDamagePerTick() {
return damagePerTick;
}
public float getTickInterval() {
return tickInterval;
}
public int getRemainingTicks() {
return remainingTicks;
}
public float getElapsedTime() {
return elapsedTime;
}
public void addElapsedTime(float dt) {
this.elapsedTime += dt;
}
public void resetElapsedTime() {
this.elapsedTime = 0f;
}
public void decrementRemainingTicks() {
this.remainingTicks--;
}
public boolean isExpired() {
return this.remainingTicks <= 0;
}
}The default constructor is required for the registration factory. Копирующий конструктор используется в clone(), который ECS вызывает внутренне, когда нужно продублировать данные компонента.
CommandBuffer
CommandBuffer ставит изменения сущностей в очередь. Используйте его вместо прямого изменения Store, чтобы обеспечить потокобезопасность и правильный порядок выполнения. Вы будете использовать его для добавления компонентов, удаления компонентов и нанесения урона.
commandBuffer.addComponent(ref, componentType, new MyComponent());
commandBuffer.removeComponent(ref, componentType);
MyComponent comp = commandBuffer.getComponent(ref, componentType);Codec
Codecs handle serialization and deserialization of components. Hytale uses them to save and load entity data to and from disk as well as sending component data over the network. When creating a custom component, you must also create a corresponding Codec.
There are mutliple Codec types already implemented in the default Codec Interface:
- Codec.STRING
- Codec.BOOLEAN
- Codec.DOUBLE
- Codec.FLOAT
- Codec.BYTE
- Codec.SHORT
- Codec.INTEGER
- Codec.LONG
- Codec.DOUBLE_ARRAY
- Codec.FLOAT_ARRAY
- Codec.INT_ARRAY
- Codec.LONG_ARRAY
- Codec.STRING_ARRAY
- Codec.PATH
- Codec.INSTANT
- Codec.DURATION
- Codec.DURATION_SECONDS
- Codec.LOG_LEVEL
- Codec.UUID_BINARY
- Codec.UUID_STRING
Aside from the basic types, there are also more complex Codec implementations like CodecMap, ObjectMapCodec or EnumCodec.
Builder Codec
The BuilderCodec is a powerful utility for creating your custom codecs. It allows you to define how each field in your component is serialized and deserialized. Each Field needs to have the following information:
- The KeyedCodec to use for serialization/deserialization of the field. This can be one of the built-in codecs or a custom codec if your field is a complex type. To initialize a KeyedCodec you need to provide a unique string identifier for the codec and the actual Codec instance to use for the field.
Keep in mind that every KeyedCodec identifier string must start Uppercase and be unique across your entire mod. This means that if you have multiple components, each field's KeyedCodec identifier must not clash with any other field's identifier in any other component within your mod. Otherwise you may run into serialization issues.
- A setter function to set the field value on the component
- A getter function to retrieve the field value from the component
This might seem tedious at first but it ensures that your component can be correctly serialized and deserialized by the ECS system. Let's take a look at each of the required parameters in detail:
-
KeyedCodec: This defines how the data is converted to and from a storable format. For example, if you have an integer field, you would use
Codec.INTEGER.// Example of creating a KeyedCodec for a String field KeyedCodec<String> example = new KeyedCodec<String>("ExampleIdForCodec", Codec.STRING); // Example of creating a KeyedCodec for an Integer field KeyedCodec<Integer> exampleInt = new KeyedCodec<Integer>("ExampleIntIdForCodec", Codec.INTEGER); -
Setter Function: This is a lambda function that takes an instance of your component and returns the value of the field you want to serialize. For example, if you have a field called
myCustomFieldin your custom component, your getter function would look like this:(data, value) -> data.myCustomField = value -
Getter Function: This is a lambda function that takes an instance of your component and returns the value of the field you want to serialize. For example, if you have a field called
myCustomFieldin your custom component, your getter function would look like this:(data) -> data.myCustomField
Lambda functions are a concise way to represent functional interfaces in Java. They allow you to pass behavior as parameters, making your code more flexible and reusable. In the context of Codec creation, they enable you to define how to get and set field values without needing to create separate classes or methods for each field.
If you are unfamiliar with lambda functions, you can view them as short and compressed methods that can be defined inline. They are particularly useful for scenarios where you need to pass simple behavior, such as getting or setting a value, without the overhead of creating a full class or method.
As an example, consider the following lambda function used as a getter:
(data) -> data.myCustomFieldThis lambda takes a single parameter data (which would be an instance of your component) and returns the value of myCustomField. It's equivalent to writing a method like this:
public Object getMyCustomField(MyComponent data) {
return data.myCustomField;
}After defining the necessary parameters, you can create a BuilderCodec for your component. As example let's look at how to create a Codec for the previously defined PoisonComponent. For the purpose of this example, let's assume that the PoisonComponent has only the following fields:
damagePerTick(float)poisonName(String)
public class PoisonComponent implements Component<EntityStore> {
private float damagePerTick;
private String poisonName;
// Constructors, getters, setters, clone method omitted for brevity
public static final BuilderCodec<PoisonComponent> CODEC = BuilderCodec.builder(PoisonComponent.class, PoisonComponent::new)
.append(
new KeyedCodec<Float>("DamagePerTick", Codec.FLOAT),
(data, value) -> data.damagePerTick = value,
(data) -> data.damagePerTick
)
.add()
.append(
new KeyedCodec<String>("PoisonName", Codec.STRING),
(data, value) -> data.poisonName = value,
(data) -> data.poisonName
)
.add()
.build();
}You can also add validators to your Codec fields to ensure that the data being serialized/deserialized meets certain criteria. For example, if you want to ensure that the damagePerTick field is always non-negative or the poison name is never null, you can add a validator like this:
.append(
new KeyedCodec<Float>("DamagePerTick", Codec.FLOAT),
(data, value) -> data.damagePerTick = value,
(data) -> data.damagePerTick
)
.addValidator(Validators.greaterThan(0))
.add()
.append(
new KeyedCodec<String>("PoisonName", Codec.STRING),
(data, value) -> data.poisonName = value,
(data) -> data.poisonName
)
.addValidator(Validators.nonNull())
.add()
### Complex Codec Examples
If you want to have a map inside your component, you can use the MapCodec class to build a KeyedCoded for it. Here's an example of how to create a Codec for a Map field:
```java
var damagePerTick = new KeyedCodec<>("DamagePerTick", new MapCodec<>(Codec.FLOAT, HashMap<String, Float>::new));This also works with custom Objects as long as you have a Codec defined for the Object type.