Entity Component System: Ashley

Category: 

Ashley ist ein kleines Entity Component System für Java. Inspiriert wurde es von den beiden Frameworks Ash und Artemis. Ashley ist Teil der LibGDX Familie und kann daher wunderbar damit benutzt werden. Selbstverständlich kann man es aber auch (fast) ohne LibGDX nutzen.

Dem Projekt hinzufügen

Je nachdem wie das Projekt bisher konfiguriert ist, gibt es verschiedene Wege Ashley hinzuzufügen.
Grundlegend unterscheide ich hier vier Fälle: Barebone, Maven, Gradle und LibGDX Setup.
Wenn einem das alles nichts sagt, verwendet man höchstwahrscheinlich gar kein Build System. In dem Fall ist Barebone der richtige Einstiegspunkt.
In der Einleitung habe ich erwähnt, dass man Ashley "fast" ohne LibGDX nutzen kann. Im Grunde ist Ashley unabhängig von dem eigentlich Framework, jedoch benutzt es ein paar Hilfsklassen von LibGDX. Wenn man also Ashley ohne LibGDX nutzen möchte, so muss man die benötigten Klassen separat bereitstellen. Diese habe ich in einem jar-Archiv bereitgestellt: GdxFakeUtils. Falls man also Ashley ohne LibGDX nutzen möchte, muss man dieses Archiv importieren. Wenn man jedoch LibGDX nutzt, darf man GdxFakeUtils eben nicht importieren, da es sonst Namenskonflikte gibt.

Barebone

Falls man keinerlei Build System nutzt, kann man sich die Ashley.jar auch direkt hier herunterladen: Ashley
Dabei wählt man am besten die neuste Version aus und lädt sich dass entsprechende Archiv herunter. Für die Version 1.5.0 ist das die Datei ashley-1.5.0.jar
Dieses Archiv muss man dann natürlich nur noch in die Entwicklungsumgebung importieren. Für Eclipse macht man einen Rechtsklick auf das Projekt -> Build Path -> Add External Archives und wählt das heruntergeladene Archiv aus.

Maven

Ashley unterscheidet zwei Releases: Official und Snapshot
Der Official Branch beinhaltet die getestet und stabilen Builds, während der Snapshot Branch die "Nightly Builds" enthält, welche immer auf dem neusten Stand sind, jedoch teilweise noch nicht ganz Fehlerfrei sind.
Je nachdem welchen Release man verwenden möchte, fügt man die passende URL in die pom.xml Datei ein:

<repositories>
  <repository>
    <id>sonatype</id>
    <name>Sonatype</name>
    <url>https://oss.sonatype.org/content/repositories/releases</url>
  </repository>
</repositories>

Für die Snapshots lautet die URL: https://oss.sonatype.org/content/repositories/snapshots

Anschließend muss man noch die entsprechende Ashley Abhängigkeit hinzufügen:

<dependency>
  <groupId>com.badlogicgames.ashley</groupId>
  <artifactId>ashley</artifactId>
  <version>1.5.0</version>
</dependency>

Gradle

Wenn man Gradle als Build System nutzt, ist das hinzufügen besonders einfach. Man muss nur das entsprechende Repository, sowie die Dependency hinzufügen.
Dazu öffnet man seine build.gradle Datei und passt die entsprechenden Elemente an:

repositories {
  maven { url "https://oss.sonatype.org/content/repositories/snapshots/" }
  maven { url "https://oss.sonatype.org/content/repositories/releases/" }
}
dependencies {
  compile "com.badlogicgames.ashley:ashley:1.5.0"
}

Wenn man die Snapshots nutzen will lautet die Dependency: com.badlogicgames.ashley:ashley:1.5.1-SNAPSHOT

LibGDX Setup

Wenn man ein frisches LibGDX Projekt startet, braucht man nur einen Haken an die Ashley Checkbox machen und der Rest geschieht automatisch. Falls man jedoch ein bestehendes Projekt um Ashley erweitern will, so muss man dies mit Gradle machen.

Framework Übersicht

Ashley besitzt eine simple und leicht zu verstehende API. Zur Veranschaulichung hier einmal das UML-Klassendiagramm:
  • Entity: Container für Komponenten
  • Component: Basis Klasse für Komponenten. Reine Datenklassen ohne Logik
  • ComponentMapper: Schneller Zugriff auf Komponenten
  • Family: Beschreibt ein Set von Entitäten, welche bestimmte Komponenten enthalten
  • Engine: Main Klasse des Frameworks. Organisiert alle Entities, Systems und Listener
  • EntitySystem: Basis Klasse für Logik. Jedes System operiert auf Entitäten mit bestimmten Komponenten
  • EntityListener: Listener für Entity Events

Ashley nutzen

Bevor wir genauer darauf eingehen, wie man Ashley benutzt, sollte man sicher sein, dass man weiß wie ein ECS überhaupt funktioniert. Dazu kann man sich folgendes Tutorial durchlesen: Entity Component System

Engine

Grundsätzlich benötigt man eine Engine. In den meisten Fällen reicht genau eine Engine für das ganze Projekt.
Engine engine = new Engine();

Entities

Weiterhin möchte man natürlich auch noch Objekte/Entitäten modellieren können. Dazu stellt Ashley die Klasse Entity bereit. Jede Entity besitzt eine eindeutige ID, sowie eine Liste von Komponenten.
Entity entity = new Entity();
long id = entity.getId();

Bevor diese Entity effektiv genutzt werden kann, muss sie noch der Engine hinzugefügt werden. Erst danach kann sie von Systemen verarbeitet werden:

engine.addEntity(entity);

Falls man eine Entity wieder entfernen möchte, kann man das wie folgt machen:

engine.removeEntity(entity);

Auf Wunsch kann man Entities auch mit Hilfe ihrer ID abfragen:

Entity entity = engine.getEntity(id);

Components

Komponenten sind nicht weiter als reine Datenklassen. D.h. sie enthalten keinerlei Logic, sondern nur Attribute. Sämtliche Logik sollte durch EntitySysteme bereitgestellt werden. Um eine neue Komponente zu erstellen, muss man nur von Component erben.
public class PositionComponent extends Component {
  public float x;
  public float y;
}

public class VelocityComponent extends Component {
  public float x;
  public float y;
}

Typischerweise verzichtet man hier auf getter/setter Methoden um die Performance zu steigern.

Um nun diese Komponenten einer Entity hinzuzufügen, muss man nichts weiter machen, als die add-Methode aufzurufen:

entity.add(new PositionComponent());
entity.add(new VelocityComponent());

Hier gilt es zu beachten, dass jede Komponente Klasse nur einmal hinzugefügt werden kann. Wenn man nun eine weitere PositionComponent hinzufügt, so ersetzt diese die alte PositionComponent.
Komponenten entfernt man wie folgt:

entity.remove(PositionComponent.class);
entity.removeAll();//entfernt alle Komponenten

Abgefragt werden die Komponenten so:

PositionComponent pos = entity.get(PositionComponent.class);

Dies hat jedoch eine Laufzeit von O(log n). Um eine Komponente in O(1) abzurufen, kann man die sogenannten ComponentMapper nutzen.

ComponentMapper

ComponentMapper erlauben sehr schnellen Zugriff auf Komponenten. Für jede Komponente die man Abrufen möchte, kann man sich einen Mapper erstellen.

ComponentMapper<PositionComponent> posMapper = ComponentMapper.getFor(PositionComponent.class);
//...
PositionComponent pos = posMapper.get(entity);

Entity Families

Eine Family gruppiert Entities mit bestimmten Komponenten. Wenn man z.B. nur die Entities betrachten möchte, welche sowohl eine PositionComponent, als auch eine VelocityComponent haben, so erzeugt man sich die folgende Family:
Family family = Family.all(PositionComponent.class, VelocityComponent.class).get();

Man kann nicht nur festlegen, welche Komponenten vorhanden sein müssen, sondern auch welche nicht vorhanden sein dürfen oder ähnliches.
Angenommen wir wollen Entities zeichnen, dafür benötigen diese eine Texture oder einen Particle, außerdem müssen sie eine Position besitzen und dürfen nicht die InvisibleComponent besitzen:

Family family = Family.all(PositionComponent.class) // Position muss vorhanden sein
  .one(TextureComponent.class, ParticleComponent.class) // Mindestens eine der beiden Komponenten muss vorhanden sein
  .exclude(InvisibleComponent.class) // Diese Komponente darf nicht vorhanden sein
  .get();

Um nun die passenden Entities zu erhalten, übergibt man diese Family an die Engine:

ImmutableArray<Entity> entities = engine.getEntitiesFor(family);

Über dieses Array kann man dann nach belieben iterieren.

Entity System

Nun kommen wir zur eigentlichen Logik. Um ein System zu implementieren, muss man erst einmal von EntitySystem erben. Die folgenden Methoden können überschrieben werden:
public abstract class EntitySystem {
  public EntitySystem();
  public EntitySystem(int priority);
  public void addedToEngine(Engine engine);
  public void removedFromEngine(Engine engine);
  public void update(float deltaTime);
  public boolean checkProcessing();
  public void setProcessing(boolean processing);
}

Als Beispiel betrachten wir einmal ein MovementSystem. Dieses soll die Position einer Entity entsprechend der Geschwindigkeit anpassen:

public class MovementSystem extends EntitySystem {
  private ImmutableArray<Entity> entities;

  private ComponentMapper<PositionComponent> pm = ComponentMapper.getFor(PositionComponent.class);
  private ComponentMapper<VelocityComponent> vm = ComponentMapper.getFor(VelocityComponent.class);

  public MovementSystem() {}

  public void addedToEngine(Engine engine) {
    entities = engine.getEntitiesFor(Family.all(PositionComponent.class, VelocityComponent.class).get());
  }

  public void update(float deltaTime) {
    for (int i = 0; i < entities.size(); ++i) {
      Entity entity = entities.get(i);
      PositionComponent position = pm.get(entity);
      VelocityComponent velocity = vm.get(entity);

      position.x += velocity.x * deltaTime;
      position.y += velocity.y * deltaTime;
    }
  }
}

In jedem Frame updated die Engine alle registrierten Systeme. Dabei werden die Systeme mit der niedrigeren Priorität zuerst geupdated.
Das MovementSystem muss nun noch der Engine hinzugefügt werden:

MovementSystem movementSystem = new MovementSystem();
engine.addSystem(movementSystem);

Bei Bedarf kann man ein System auch wieder entfernen oder auch nur deaktivieren, wodurch es nicht mehr geupdated wird:

engine.removeSystem(movementSystem); // System entfernen
movementSystem.setProcessing(false); // System deaktivieren

Ashley beinhaltet bereits einige Standartsysteme, welche einem bereits einiges an Arbeit abnehmen: Built-in Entity Systems

Entity Events

Durch implementieren des EntityListener, kann man benachrichtigt werden, sobald eine Entity hinzugefügt oder entfernt wird.

public interface EntityListener {
  public void entityAdded(Entity entity);
  public void entityRemoved(Entity entity);
}
// ...
engine.addEntityListener(listener);

Manchmal möchte man aber nicht über jede Entity benachrichtigt werden, sondern nur über bestimmte:

Family family = Family.all(PositionComponent.class, MovementComponent.class).get();
engine.addEntityListener(family, listener);

Engine updaten

Nun ist alles wichtige bereit und wartet nur darauf, dass die Engine startet. Dazu sollte in jedem Frame die update-Methode der Engine aufgerufen werden:
engine.update(deltaTime);

Dieser Aufruf sorgt dafür, dass alle aktiven Systeme der Reihe nach geupdated werden.

Site Notes

Es gibt ein paar Spezialfälle, welche von Ashley besonders behandelt werden.

  • Wenn ein EntitySystem eine Entity entfernt, so wird diese erst entfernt, wenn das aktuelle System fertig ist.
  • Wenn ein EntitySystem eine Komponente hinzufügt oder entfernt, so wird diese Aktion auch verzögert, bis das System fertig ist.