Hytale Modding
Hytale Modding

Hytale ECS Teorija

Šajā pamācībā uzzināsi par Hytale jaudīgās ECS sistēmas pamatiem, kā arī izveidosi savu komponentu, sistēmu un strādāsi kopā ar citām sistēmām, lai izveidotu spēles loģiku.

Store/Glabātuve

Store klase ir Hytale ECS sistēmas kodols, kas atbild par vienību glabāšanu. Ja jebkad nepieciešams piekļūt vienībai, tad jāpiekļūst glabātuvei. Tā izmanto Archetypes konceptu, kur dati ir sagrubēti gabalos. Piemēram, ja mums ir 100 Trorki, tie būs sagrupēti chunk/gabalos kopā ar to komponentiem, lai tie būtu vieglāk un ātrāk pieejami.

EntityStore

Skatoties cauri Hytale servera kodam, tu atradīsi StoreEntityStore tipu. Tā nosaukums var būt maldinošs, jo vārdu salikums arī liecina par Vienību Glabāšanu. Bet vai tad tikko neizrunājām, ka Store jau tieši to dara? EntityStore klase implementē WorldProvider, kas nozīmē, ka EntityStore atbild par pieeju specifiskām Hytale World pasaulēm. Tas uztur iekšējās kartes entitiesByUuid un networkIdToRef, kas ļauj atrast specifiskas vienībes pēc to patstāvīgajiem ID, vai tīkla ID.

Katrai Entity/Vienībai ir gan UUIDComponent, gan NetworkId, ko izmanto EntityStore, lai uzmeklētu vienības iekš Store.

ChunkStore

Vēl viens Store tips ar ko varētu sastapties ir ChunkStore. Tas atbild par visu bloku komponentu glabāšanu, kas saistīti ar World. Tu vari iegūt WorldChunk-us, kas ir vispārējie gabalu Komponenti. WorldChunk komponenta sastāvā ir EntityChunk, kas tur visas Vienības, kas ir iekš gabala, kā arī to references uz EntityStore. Tas arī tur BlockChunk, kas sastāv no BlockSection-s. Pastāv vēl citi komponenti, kas veido vispārējo pasauli un gabalu sistēmu, bet pagaidām šīs ir pamata zināšanas priekš ChunkStore. Tu vari to izmantot, lai iegūtu datus par gabaliem, to blokiem, vienībām, kas atrodas gabalā un veidot bloku, un gabalu sistēmas.

Holder

Holder/Turētājs pamatā ir vienības plāns. Pirms vienība eksistē Glabātuvē (un pasaulē), tā eksistē kā Holder. Tā ievāc un tur nepieciešamos komponentus (datus). Tu vari salīdzināt to ar analogu, kā iepirkšanās grozu. Tu vispirms paņem visus vajadzīgos komponentus veikalā un tikai tad dodies uz kasi, kas paņems groza saturu, izveidos derīgus vienības ID un atdos tos atpakaļ ar čeku (Ref).

Apskatīsim kādu piemēru: Spēlētāja inicializācija. Iekš Universe, addPlayer metode demonstrē to perfekti. Kad spēlētājs savienojas, mēs uzreiz neiemetam viņu ECS. Mēs vispirms izveidojam spēlētāja datus iekš Holder. Pievērs uzmanību PlayerStorage#load metodei, kad tā ielādē spēlētāju no diska un atgriež CompletableFuture<Holder<EntityStore>>. Metode ir asinhroniska un nākotnē turēs Holder kaut kam iekš EntityStore. Atver Universe klasi, atrodi addPlayer metodi un izlasi to no sākuma līdz beigām. Tici man, tas ļoti palīdzēs tad, kad redzēsi kā vienība tiek izveidota un caur kurieni tā tiek izlaista. Beigās Universe izsauc world#addPlayer, kas (pēc notikuma) izsauc brīnišķīgo

Ref<EntityStore> ref = playerRefComponent.addToStore(store);

un PlayerRef#addToStore satur šo:

store.addEntity(this.holder, AddReason.LOAD);

Ref (Reference)

Tiem, kas pazīstami ar C++, jūs varbūt jau varat minēt ko šī klase dara pēc nosaukuma. Tikai šeit Ref ir drošs "segvārds", vai rādītājs uz vienību. Tev NEKAD nevajadzētu turēt tiešu atsauci uz vienības objektu, bet tā vietā izmanto Ref. Tas seko vai vienība ir joprojām dzīva. Ja tu izsauc validate() uz Ref priekš vienības, kas izdzēsta, tad izmetīs exception/izņēmumu.

Spēlētāja Komponenti

Iekš Hytale "Player" nav tikai viens objekts. Tā ir viena vienība, kas satur vairākus specializētus komponentus. Modificēšanai ir svarīgi saprast atšķirību starp Player un PlayerRef.

PlayerRef

Neskatoties uz tā nosaukumu, PlayerRef ir komponents, nevis abstrakta reference. Tas reprezentē spēlētāja savienojumu un identitāti. Tas ir speciāls komponents, kas paliek aktīvs, kamēr spēlētājs ir savienots ar serveri, pat, ja spēlētājs nomaina pasaules. Galvenie dati, ko tas glabā, ir spēlētāja lietotājvārds, UUID, valoda, kā arī pakešu apstrādātājs.

Player

Player komponents reprezentē spēlētāja fizisko klātbūtni. Tas eksistē tikai tad, kad spēlētājs ir ievietots pasaulē. Nodrošinot piekļuvi spēles specifiskiem datiem, šis komponents atšķiras atkarībā no pasaules.

Lai mijiedarbotos ar vienību, tu izmanto Store, lai iegūtu tā komponentus caur ComponentType. Tā kā Hytale izmanto atāķētu sistēmu, tu neizsauc entity.getHealth(). Tā vietā, tu prasi Store sagādāt veselības datus saistītus ar tās vienības 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()));
}

Šeit mēs izmantojam Store<EntityStore>, lai piekļūtu Player komponentam izmantojot Ref<EntityStore>. Mēs varam darīt to pašu ar citiem komponentiem, piemēram, 'UUIDComponent' vai 'TransformComponent', lai izgūtu entītiju Transform, kas satur pozīciju un rotāciju.

Komponenti

Komponenti ir tīri datu konteineri. Tie tur statusu, bet ne loģiku. Iekš Hytale, komponentiem jāimplementē Component<EntityStore> un jāsniedz metodes klons priekš ECS, lai ECS kopētu to pēc vajadzības.

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;
  }
}

Noklusējuma konstruktors ir nepieciešams reģistrācijas rūpnīcai. clone() izmanto kopēšanas konstruktoru, ko ECS izsauc iekšēji pēc vajadzības, lai dublicētu komponenta datus.

CommandBuffer

CommandBuffer sastāda rindu vienību izmaiņām. Izmanto to, nevis tieši modificē glabātuvi, lai nodrošinātu drošību un pareizu kārtību. Tu to izmantosi, lai pievienotu komponentus, noņemtu komponentus un veiktu bojājumus.

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.
Warning

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 myCustomField in 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 myCustomField in your custom component, your getter function would look like this:

    (data) -> data.myCustomField
Info

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.myCustomField

This 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));
Info

This also works with custom Objects as long as you have a Codec defined for the Object type.