Frameworks: How-To Dungeon

TL;DR

Der PM-Dungeon ist ein Framework zum Entwickeln von Rogue-like Dungeon-Crawlern, also einfachen 2D-Spielen in Java. Das Framework bietet die wichtigsten benötigten Grundstrukturen für ein Computer-Spiel: Es hat eine Game-Loop, kann Level generieren und darstellen und hat eine Entity-Component-System-Struktur (ECS), über die die Spielinhalte erstellt werden können. Im Hintergrund arbeitet die Open-Source-Bibliothek libGDX.

Sie können das Projekt direkt von GitHub clonen und über den im Projekt integrierten Gradle-Wrapper starten. Dazu brauchen Sie Java 21 LTS (in einer 64-bit Version). Sie können das Projekt als Gradle-Projekt in Ihre IDE laden.

Die Starter-Klassen (z.B. starter.Starter im "dungeon"-Subprojekt, starter.DojoStarter im "dojo-dungeon"-Subprojekt oder starter.DevDungeon im "devDungeon"-Subprojekt) sind die zentralen Einstiegspunkte. Hier finden Sie "unseren" Teil der Game-Loop (der in der eigentlichen Game-Loop von libGDX aufgerufen wird), hier finden Sie die Konfiguration und die main()-Methode.

Im ECS werden die im Spiel befindlichen Elemente als Entitäten modelliert. Diese Entitäten sind lediglich Container für Components, die dann ihrerseits die entsprechenden Eigenschaften der Entitäten modellieren. Entitäten haben normalerweise über die Components hinaus keine weiteren Eigenschaften (Attribute, Methoden). Das Game kennt alle zum aktuellen Zeitpunkt "lebenden" Entitäten.

Components gruppieren Eigenschaften, beispielsweise für Positionen oder Lebenspunkte. Components haben normalerweise keine Methoden (halten also nur Werte/Attribute). Jede Component-Instanz ist immer einer konkreten Entität zugeordnet und kann ohne diese nicht existieren.

Systeme implementieren das Verhalten im ECS. Das Game kennt alle aktiven Systeme und ruft in jedem Durchlauf der Game-Loop die execute()-Methode der Systeme auf. Üblicherweise holt sich dann ein System alle Entitäten vom Game und iteriert darüber und fragt ab, ob die betrachtete Entität die notwendigen Components hat - falls ja, dann kann das System auf dieser Entität die entsprechenden Operationen ausführen (Animation, Bewegung, ...); falls nein, wird diese Entität ignoriert und mit der Iteration fortgefahren.

Wir programmieren in dieser Einheit einen einfachen Helden. Der Held ist eine Entity und braucht verschiedene Components, um im Spiel angezeigt zu werden und bewegt werden zu können.

Lernziele
  • (K2) Überblick über die wichtigsten Strukturen im PM-Dungeon
  • (K2) Aufbau eines ECS: Entitäten, Komponenten, Systeme
  • (K3) Herunterladen und installieren des PM-Dungeon
  • (K3) Laden in der IDE
  • (K3) Erstellen eines Helden mit Animation und Bewegung

How-To Dungeon

In diesem Semester werden Sie im Praktikum schrittweise Erweiterungen in verschiedenen "fertigen" Rogue-like Computerspielen programmieren und dabei (hoffentlich) die Methoden aus der Vorlesung einsetzen können.

Das Projekt "PM-Dungeon" stellt wichtige Bausteine für das Spiel bereit, beispielsweise eine Game-Loop und eine API für das Generieren und Benutzen von Leveln und vieles andere mehr. Im Hintergrund werkelt das etablierte Open-Source-Spieleframework libGDX.

Wir werden uns in diesem How-To einen Überblick verschaffen und einen ersten Einstieg versuchen: Wir programmieren einen einfachen Helden.

Projekt PM-Dungeon

Das Projekt PM-Dungeon entstand in verschiedenen Forschungsprojekten und wurde (und wird) aktiv von Studierenden und wissenschaftlichen Mitarbeitern am Campus Minden entwickelt.

Zuletzt lief das Forschungsprojekt "Dungeon", gefördert durch die Stiftung für Innovation in der Hochschullehre im "Freiraum 2022". Dabei sollten diesmal nicht die Studierenden selbst Code schreiben, sondern die Lehrenden sollen Aufgaben in einer speziellen (von uns entwickelten) Programmiersprache schreiben (können), woraus dann ein fertiges Dungeon-Spiel generiert wird (mit der Aufgabe als Quest o.ä. im Dungeon eingebettet) und die Studierenden können durch das Spielen die Aufgaben lösen.

Sie werden merken, dass trotz klarer Richtlinien und Ideen die Entwicklung in der Praxis doch nicht so einfach ist und dass viele Dinge immer wieder geübt und erinnert werden müssen: Namen von Klassen und Methoden, sinnvolles Javadoc, Dokumentation jenseits des Javadoc, aber auch Commit-Messages und PR-Summaries.

Installation des Frameworks

Sie finden das Projekt auf GitHub: github.com/Dungeon-CampusMinden/Dungeon.

Laden Sie sich den Quellcode herunter, um damit in der IDE arbeiten zu können. Prinzipiell gibt es viele verschiedene Wege, in diesem Tutorial laden wir es per Git in der Konsole herunter:

git clone git@github.com:Dungeon-CampusMinden/Dungeon.git pm-dungeon

Dabei entsteht der Ordner pm-dungeon/ mit dem Dungeon-Projekt als Inhalt.

WICHTIG: Achten Sie bitte darauf, dass im Projektpfad keine Leerzeichen und keine Sonderzeichen (Umlaute o.ä.) vorkommen! Dies kann zu seltsamen Fehler führen. Bitte auch darauf achten, dass Sie als JDK ein Java SE 21 (LTS) verwenden.

Java: Java SE 21 (LTS)

Wir benutzen im Dungeon-Projekt die aktuelle LTS-Version des JDK, d.h. Java SE 21 (LTS). Sie können sich das JDK bei Oracle herunterladen oder Alternativen ausprobieren. Bitte unbedingt die jeweilige 64-bit Version nutzen!

In der Konsole sollte

java -version

ungefähr diese Ausgabe erzeugen (ignorieren Sie die Minor-Version, wichtig ist Major-Version: 21 bzw. "LTS"):

java version "21.0.3" 2024-04-16 LTS
Java(TM) SE Runtime Environment (build 21.0.3+7-LTS-152)
Java HotSpot(TM) 64-Bit Server VM (build 21.0.3+7-LTS-152, mixed mode, sharing)

Erster Test

Für einen ersten Test gehen Sie in der Konsole in den vorhin erzeugten neuen Ordner pm-dungeon/ und führen Sie dort den Befehl

./gradlew game:runBasicStarter

aus. Dabei sollte das (mitgelieferte) Build-Tool Gradle starten und die benötigten Java-Bibliotheken herunterladen und schließlich das Spiel in einer Minimalversion starten - Sie sollten also ein Level sehen.

Dies dauert je nach Internetanbindung etwas - beim nächsten Start geht es dann aber deutlich schneller, weil ja bereits alles da ist.

Import in der IDE

Importieren Sie das Projekt als Gradle-basiertes Projekt, dann übernimmt die IDE die Konfiguration für Sie.

Über das Gradle-Menü können Sie nun in der IDE den "runBasicStarter"-Task (Menüpunkt "game") starten, und es erscheint wieder ein minimales Level.

Überblick über die (Sub-) Projekte

Sie finden im Package-Explorer eine Reihe von Unterprojekten (Gradle-Subprojekte). Für PR2 sind eigentlich nur die Subprojekte "dojo-dungeon" und "devDungeon" relevant sowie die Dokumentation in den verschiedenen doc/-Ordnern (die derzeit leider noch eine ziemliche Baustelle ist).

Dojo-Dungeon und DevDungeon stellen zwei verschiedene (mehr oder weniger fertige) Spiele dar, die von Studierenden erstellt wurden (Dojo-Dungeon: @Denniso3, @tgrothe und @JudiTeller; DevDungeon: @Flamtky). Diese Spiele nutzen wir an einigen Stellen im Praktikum.

Die Basis für die beiden Spiele stellt das Dungeon-Framework dar, welches in den Gradle-Subprojekten "game" und "dungeon" zu finden ist. Game stellt dabei eine Art minimale Basis zum Programmieren eigener Spiele dar (alle Klassen im Package core), und Dungeon erweitert diese Basis und fügt einige häufig benötigte Elemente und weitere Texturen (Package contrib) hinzu. Zusätzlich gibt es hier noch einige Klassen für die DSL, was für PR2 aber nicht relevant ist.

Das Subprojekt "blockly" ist die Einbindung einer blockbasierten Programmiersprache in das Dungeon-Framework und spielt für PR2 ebenfalls keine Rolle.

Die Strukturen in allen Sub-Projekten ist ähnlich: Sie finden unter <subproject>/src/ die Java-Packages und in <subproject>/assets/ vordefinierte Texturen und Soundfiles sowie Crafting-Rezepte (beispielsweise für Boden, Wände und den Hero). Alle Sourcen sind (mehr oder weniger) mit Javadoc dokumentiert, zusätzlich gibt es jeweils in <subproject>/doc/ weitere Anleitungen und Hinweise.

Für die Aufgaben im Praktikum starten Sie am besten zunächst beim relevanten Code in den Sub-Projekten Dojo-Dungeon und DevDungeon. Schauen Sie sich die für die Aufgabe benutzten Klassen und deren Javadoc an. In der Regel nutzen diese auch Klassen aus Dungeon und Game, deren Aufbau und Javadoc Sie sich ebenfalls anschauen sollten. Zusätzlich gibt es für Game und Dungeon noch weitere Dokumentation in den doc/-Ordnern.

Überblick über die Java-Strukturen

Jedes Spiel besteht aus einer Game-Loop, die je nach Konfiguration 30 Mal oder 60 Mal pro Sekunde ausgeführt wird. Diese Game-Loop wird mit Hilfe der Game#run()-Methode gestartet und die Kontrolle geht dabei vollständig an libGDX über. Im Wesentlichen werden pro Durchlauf ("Frame") die Aktionen berechnet und das Spielfeld neu gezeichnet. Alle Aktionen im Spiel, etwa das Bewegen von Spielelementen oder das Berechnen von Angriffen o.ä., werden über sogenannte Systeme berechnet. Diese werden einmal pro Frame aufgerufen und bestimmen den neuen Zustand (Position, Animation, Stats, ...) der Spielelemente, die dann beim nächsten Rendern im Spiel angezeigt werden.

Die Klasse core.Game ist der zentrale Einstiegspunkt. Hier werden alle wichtigen Dinge konfiguriert, und es gibt die Game#run()-Methode, die das Spiel startet. Zusätzlich gibt es weitere Methoden, die für Sie relevant sind:

  • Game#userOnSetup(): Diese Methode wird einmal beim Start des Spiels aufgerufen und kann für die Konfiguration und Initialisierung der verschiedenen Systeme genutzt werden. Hier wird beispielsweise u.a. auch das erste Level geladen.
  • Game#userOnFrame(): Diese Methode wird zu Beginn eines jeden Frame aufgerufen, noch bevor die execute()-Methode der verschiedenen Systeme aufgerufen wird.
  • Game#userOnLevelLoad(): Diese Methode wird aufgerufen, wenn ein Level geladen wird. Hier können Sie später die Entitäten erstellen, die initial im Level verteilt werden sollen.

Es gibt noch eine ganze Reihe von Packages, beispielsweise core.Component mit verschiedenen wichtigen Components oder core.level mit Klassen zum Generieren zufälliger neuer Level und zum Laden und zum Zugriff (wo bin ich und warum?) oder core.systems mit den Systemen, die bestimmte Dinge im Spiel managen. Die Gliederung in Entitäten (entities), Komponenten (components) und Systeme (systems) nennt sich auch "ECS-Architektur" (zu ECS später mehr).

Sie finden im "Quickstart: How to Dungeon" eine gute Anleitung, die auf die Strukturen tiefer eingeht.

Mein Held

Um einen besseren Blick in das System zu bekommen, erstellen wir schrittweise einen eigenen einfachen Helden.

Legen Sie sich im starter-Package eine neue Klasse an, mit der Sie das Spiel konfigurieren und starten können:

package starter;
import core.Game;

public class Main {
    public static void main(String... args) {
        // Start the game loop
        Game.run();
    }
}

In IntelliJ können Sie nun die main()-Funktion direkt ausführen, dazu wird im Hintergrund die vorhandene Gradle-Konfiguration genutzt. Mit anderen IDEs funktioniert das vielleicht nicht direkt, dann erweitern Sie einfach die Gradle-Konfiguration um einen entsprechenden Task:

tasks.register('run', JavaExec) {
    mainClass = 'starter.Main'
    classpath = sourceSets.main.runtimeClasspath
}

Einschub: ECS oder Entities, Components und Systems

Der Held ist ein Element im Spiel. Diese Struktur muss geeignet modelliert werden.

Unser Dungeon implementiert dabei eine Variante eines Entity Component System (ECS) und folgt damit "großen Vorbildern" wie beispielsweise Unity.

Neben verschiedenen Hilfsstrukturen gibt es dabei nur Entitäten, Komponenten und Systeme. Hier werden sämtliche Informationen und Verhalten modelliert.

Entity

Die Idee dahinter ist: Alle Elemente im Spiel werden als Entität realisiert, d.h. der Held und die Monster und die Items, die man so finden kann, sind alles Entitäten. Sogar Feuerbälle sind letztlich Entitäten. (Im Prinzip könnten sogar die Boden- und Wandkacheln Entitäten sein - sind es aus Effizienzgründen aktuell aber nicht.)

Eine Entität an sich kann erst einmal nichts und dient nur als Container für Components.

Das Spiel kennt alle zu einem Zeitpunkt vorhandenen Entitäten, diese müssen per Game#add registriert werden. Man kann die Entitäten über die API abrufen (Game#allEntities, Game#find und Game#hero).

Unsere Basisklasse für Entitäten ist aktuell core.Entity.

Component

Components bündeln bestimmte Werte einer Entität für bestimmte Zwecke, d.h. statt der Attribute in einer Klasse (Entität) nutzen wir hier eine weitere Kapselung.

Beispielsweise könnte man die Lebenspunkte u.ä. in einer HealthComponent verpacken und dann in einer Entität speichern. Oder man könnte in einer VelocityComponent hinterlegen, wie schnell eine Entität in x- und in y-Richtung bewegt werden kann (Wände würden dabei einfach den Wert 0 bekommen). Oder man könnte in einer PositionComponent speichern, wo die Entität gerade ist. Schauen Sie einfach mal in die Packages core.components und contrib.components.

Wichtig ist: Eine Instanz einer Component ist immer an eine Entität gekoppelt, eine Component ohne (Bindung an eine) Entität ist sinnfrei. Andersherum kann eine Entität immer nur eine einzige Instanz einer bestimmten Component (eines Component-Typs) haben, also beispielsweise nicht zwei Objekte vom Typ PositionComponent.

Components speichern vor allem Werte und haben nur in Ausnahmefällen eigenes Verhalten.

Das Basisinterface für Components ist derzeit core.Component.

System

Mit Entitäten und passenden Components, über die wir die Eigenschaften ausdrücken, können wir bereits Spielelemente im Dungeon repräsentieren.

Für die Bewegung und Interaktion sorgen nun passende Systeme. Das Spiel kennt alle Systeme (diese werden einmal beim Start im Spiel per Game#add registriert) und ruft in der Game-Loop pro Frame deren execute()-Methode auf. In der Regel iterieren die Systeme beim Ausführen der execute()-Methode über die Entitäten des Spiels (via Game#allEntities), suchen sich Entitäten mit bestimmten Components heraus und bearbeiten den Zustand dieser Components.

Dabei könnte beispielsweise ein HealthSystem sich alle Entitäten filtern, deren HealthComponent unterhalb einer kritischen Schwelle liegen und diese rot anmalen lassen, d.h. in der DrawComponent wird die Textur ("Animation") ausgetauscht. Oder ein PlayerSystem könnte dafür sorgen, dass die Eingaben auf der Tastatur geeignet an den Helden weitergegeben werden und (über andere Systeme) in eine Bewegung oder Kampf o.ä. umgewandelt werden.

Sie finden unsere Systeme in den Packages core.systems und contrib.systems, und die Basisklasse ist derzeit core.System - falls Sie einmal eigene Systeme implementieren wollen. (vgl. auch Doku)

Nun aber Helden!

Ein Held ist eine Entität

Also legen wir nun endlich einen neuen Helden als Instanz von core.Entity an und registrieren diese Entität im Spiel:

public class Main {
    public static void main(String... args) {

        // Add some one-time configuration
        Game.userOnSetup(
                () -> {
                    Entity hero = new Entity("Hero");
                    Game.add(hero);
                });

        // Start the game loop
        Game.run();
    }
}

Der in der Methode Game#userOnSetup übergebene Lamda-Ausdruck wird (später) einmalig beim Start der Game-Loop von libGDX aufgerufen. Auf diese Weise können wir unseren Helden ins Spiel bekommen. (Alle anderen Entitäten sollten Sie besser über die Methode Game#onLevelLoad anlegen, also beim Laden eines neuen Levels.)

Prinzipiell haben Sie damit alles, um das Spiel starten zu können. In der Praxis sehen Sie aber keinen Helden: Der hat nämlich weder eine Position noch eine Textur, kann also gar nicht angezeigt werden.

Wo bin ich grad?

Der Held braucht eine Position. Dazu gibt es core.components.PositionComponent. Fügen wir diese einfach dem Helden hinzu:

public class Main {
    public static void main(String... args) {

        // Add some one-time configuration
        Game.userOnSetup(
                () -> {
                    Entity hero = new Entity("Hero");

                    hero.add(new PositionComponent());

                    Game.add(hero);
                });

        // Start the game loop
        Game.run();
    }
}

Wenn man keine Position mitgibt, wird einfach eine zufällige Position im Level genutzt. Alternativ kann man eine eigene Position mitgeben.

Im Dungeon existieren aktuell zwei Koordinatensysteme: core.level.utils.Coordinate (Integer-basiert) und core.utils.Point (Float-basiert). Die Level werden als Matrix von Tile (Boden, Wand, Loch, ...) gespeichert. Die Position dieser Tile wird als Coordinate gespeichert, was dem Index des Tiles in der Matrix entspricht. Entitäten können aktuell aber auch zwischen zwei Tiles oder schräg-links-oben auf einem Tile stehen, dafür gibt es die Positionen als Point. Entsprechend könnte man den neuen Helden bei (0,0) in das Level setzen: new PositionComponent(new Point(0, 0)) bzw. kurz new PositionComponent(0f, 0f) (wobei diese Position möglicherweise nicht spielbar ist, da hier eine Wand oder sogar nichts ist).

Wenn Sie jetzt das Spiel starten, sehen Sie - immer noch nichts (außer den Wänden). Hmmm.

Animateure

Um den Held zeichnen zu können, brauchen wir eine Animation - also eine DrawComponent.

public class Main {
    public static void main(String... args) {

        // Add some one-time configuration
        Game.userOnSetup(
                () -> {
                    Entity hero = new Entity("Hero");

                    hero.add(new PositionComponent());

                    try {
                        hero.add(new DrawComponent(new SimpleIPath("character/knight")));
                    } catch (IOException e) {
                        System.err.println("Could not load textures for hero.");
                        throw new RuntimeException(e);
                    }

                    hero.add(new CameraComponent());
                    hero.add(new PlayerComponent());

                    Game.add(hero);
                });

        // Start the game loop
        Game.run();
    }
}

In den Asset-Ordnern der Sub-Projekte Game und Dungeon gibt es bereits vordefinierte Texturen. Im Beispiel wird (nur) im Sub-Projekt "game" gesucht (weil unsere Main-Klasse dort liegt), und zwar in <game>/assets/character/knight/. Dort finden sich Unterordner für verschiedene Zustände des Ritters, und darin jeweils einige Texturen (einfache kleine .png-Dateien), die als Animation in einem bestimmten Zustand nacheinander abgespielt werden. Über den angegebenen (Teil-) Pfad werden in DrawComponent automatisch die entsprechenden Animationen erzeugt und geladen. Die Asset-Ordner sind in der Gradle-Konfiguration definiert. (Wenn Sie Ihre Main-Klasse in Dungeon ansiedeln, stehen Ihnen automatisch die Texturen aus Dungeon plus aus Game zur Verfügung.)

Da es passieren kann, dass der übergebene Pfad nicht gefunden wird, muss hier mit Exception-Handling gearbeitet werden. Wir geben hier erstmal eine Fehlermeldung aus und propagieren eine neue RuntimeException, die letztlich dafür sorgt, dass das Spiel abgebrochen würde.

Zusätzlich brauchen wir für den Helden noch eine CameraComponent. Das core.systems.CameraSystem wird dafür sorgen, dass die Entität mit dieser Component immer im Fokus der Kamera ist. Da wir den Held später noch manuell steuern wollen, bekommt er auch gleich noch eine PlayerComponent.

Jetzt wackelt der Held auf der Stelle herum ...

Bewege mich

Für die Bewegung ist das VelocitySystem zuständig. Dieses fragt in allen Entitäten die VelocityComponent sowie die PositionComponent ab, berechnet die nächste neue Position und speichert diese in der PositionComponent, und setzt bei tatsächlicher Bewegung auch eine passende Bewegungsanimation in der DrawComponent.

Das PlayerSystem und die PlayerComponent sorgen im Zusammenspiel für eine Reaktion auf die Tastatureingaben.

public class Main {
    public static void main(String... args) {

        // Add some one-time configuration
        Game.userOnSetup(
                () -> {
                    Entity hero = new Entity("Hero");

                    hero.add(new PositionComponent());

                    try {
                        hero.add(new DrawComponent(new SimpleIPath("character/knight")));
                    } catch (IOException e) {
                        System.err.println("Could not load textures for hero.");
                        throw new RuntimeException(e);
                    }

                    hero.add(new CameraComponent());

                    hero.add(new VelocityComponent(5f, 5f));

                    PlayerComponent pc = new PlayerComponent();
                    pc.registerCallback(
                            KeyboardConfig.MOVEMENT_UP.value(),
                            entity -> {
                                VelocityComponent vc = entity.fetch(VelocityComponent.class).get();
                                vc.currentYVelocity(vc.yVelocity());
                            });
                    hero.add(pc);

                    Game.add(hero);
                });

        // Start the game loop
        Game.run();
    }
}

Die VelocityComponent wird im Konstruktor mit einer (maximalen) Geschwindigkeit in x- und y-Richtung erzeugt. Nutzen Sie hier nicht zu große Werte - unter Umständen reicht dann ein einziger Tastendruck, um einmal über das Spielfeld geschleudert zu werden.

Über die Methoden VelocityComponent#xVelocity und VelocityComponent#yVelocity können Sie die Maximalgeschwindigkeit abfragen und auch setzen. Mit VelocityComponent#currentXVelocity bzw. VelocityComponent#currentYVelocity holen und setzen Sie dagegen die aktuelle Geschwindigkeit, die vom VelocitySystem zur Berechnung der nächsten Position genutzt wird (wobei die Maximalgeschwindigkeit als Obergrenze verwendet wird).

Im Beispiel wird in der PlayerComponent des Helden der Taste "W" ein Lambda-Ausdruck zugeordnet, der die VelocityComponent der Entität (also des Helden) holt, die maximale Geschwindigkeit in y-Richtung ausliest und diese als aktuelle Geschwindigkeit in y-Richtung setzt. Damit kann mit der Taste "W" der Held nach oben laufen.

Anmerkung: Das entity.fetch(VelocityComponent.class) liefert nicht direkt ein VelocityComponent-Objekt zurück, sondern ein Optional<VelocityComponent>. Darüber sprechen wir (später) noch in der Lektion “Optional”. Für jetzt soll es zunächst genügen, dass Sie das gewünschte "verpackte" Objekt mit der Methode get() aus dem Optional wieder herausbekommen.

Anmerkung: Das gezeigte Schema ist insofern typisch, als dass verschiedene Systeme aus der Maximalgeschwindigkeit und weiteren Parametern die aktuelle Geschwindigkeit berechnen und in der VelocityComponent einer Entität setzen. Das VelocitySystem nutzt dann die aktuelle Geschwindigkeit für die tatsächliche Bewegung. Sie sollten in der Praxis also die Methoden VelocityComponent#currentXVelocity bzw. VelocityComponent#currentYVelocity eher nicht selbst aufrufen, sondern dies den Systemen überlassen. Wenn Sie einen Geschwindigkeitsboost haben wollen, würde es bei der obigen Konfiguration ausreichen, VelocityComponent#xVelocity und/oder VelocityComponent#yVelocity zu setzen/zu erhöhen - den Rest übernehmen dann das PlayerSystem und vor allem das VelocitySystem ...

Nun sollten Sie Ihren Helden (nach oben) bewegen können. (Tipp: Probieren Sie "W".)

Hinweis: Üblicherweise bearbeiten die Systeme bei der Iteration über alle Entitäten nur diejenigen Entitäten, die alle benötigten Components aufweisen.

Walking mit System

Neue Monster

Wie kann ich ein Monster beim Laden des Levels erzeugen?

Beim Laden eines Levels wird der mit Game#userOnLevelLoad registrierte Lambda-Ausdruck ausgeführt. Hier kann man beispielsweise ein neues Monster erzeugen (lassen):

public class Main {
    public static void main(String... args) {

        // Add some one-time configuration
        Game.userOnSetup( ... );


        // Create a new monster in every new level
        Game.userOnLevelLoad(first -> {
            Entity fb = new Entity("HUGO");

            fb.add(new PositionComponent(Game.hero().get().fetch(PositionComponent.class).get().position()));

            try {
                fb.add(new DrawComponent(new SimpleIPath("character/knight")));
            } catch (IOException e) {
                System.err.println("Could not load textures for HUGO.");
                throw new RuntimeException(e);
            }

            VelocityComponent vc = new VelocityComponent(10f, 10f);
            vc.currentYVelocity(vc.yVelocity());
            fb.add(vc);

            Game.add(fb);
        });


        // Start the game loop
        Game.run();
    }
}

Im Lambda-Ausdruck erzeugen wir hier einfach eine neue Entität und fügen dieser wie vorhin beim Hero eine DrawComponent für die Anzeige sowie eine PositionComponent und eine VelocityComponent für die Position und Bewegung hinzu, und am Ende registrieren wir die Entität beim Spiel.

Wenn man das Spiel jetzt startet, wird an der Position des Helden eine neue Entität sichtbar (mit der selben Textur).

Aber warum bewegt die neue Figur sich nicht? Wir haben doch eine VelocityComponent hinzugefügt und eine aktuelle Geschwindigkeit gesetzt?!

Wenn man in VelocitySystem#execute (bzw. die dort aufgerufene Methode VelocitySystem#updatePosition) schaut, wird klar, dass die aktuelle Geschwindigkeit zwar neu berechnet und gesetzt wird, aber dass ein "Reibungsfaktor" (abhängig vom Feld, auf dem die Figur steht) eingerechnet wird und somit die aktuelle Geschwindigkeit schnell auf Null geht. Der Hintergrund ist einfach: Normalerweise soll eine Entität nicht einmal angeschubst werden und dann "ewig" laufen, insbesondere bei Reaktion auf Tastatureingaben. Deshalb werden die Entitäten kurz bewegt und bremsen dann wieder ab. Das Aufrechterhalten der Bewegung erfolgt normalerweise über Systeme ...

Systems für das selbstständige Laufen

Wir brauchen ein System, welches die aktuelle Geschwindigkeit einer Entität in jedem Frame wieder auf den alten Wert setzt. Dazu leiten wir von core.System ab. (Achtung: Es gibt auch eine Klasse System im JDK - hier müssen Sie genau hinschauen!)

import core.System;

public class WalkerSystem extends System {
    @Override
    public void execute() {
        entityStream().forEach(e -> {
                VelocityComponent vc = e.fetch(VelocityComponent.class).get();
                vc.currentXVelocity(vc.xVelocity());
                vc.currentYVelocity(vc.yVelocity());
        });
    }
}


public class Main {
    public static void main(String... args) {

        // Add some one-time configuration
        Game.userOnSetup( ... );

        // Create a new monster in every new level
        Game.userOnLevelLoad( ... );


        // Register our new system
        Game.add(new WalkerSystem());


        // Start the game loop
        Game.run();
    }
}

Wir leiten also von core.System ab und implementieren die execute-Methode. Wir holen uns dabei von jeder Entität die VelocityComponent und setzen die aktuelle Geschwindigkeit neu auf die maximale Geschwindigkeit. Zusätzlich registrieren wir das neue System im Spiel, damit es in jedem Frame einmal aufgerufen wird.

Nun läuft das neue Monster los (bis es gegen eine Wand läuft).

Aber der Held bewegt sich nun ebenfalls dauerhaft :(

Components für das selbstständige Laufen

Das Problem ist, dass unser neues WalkerSystem alle Entitäten automatisch bewegt. (Ein weiteres Problem ist, dass das WalkerSystem davon ausgeht, dass es immer eine VelocityComponent gibt, was nicht unbedingt erfüllt ist!)

Wir brauchen also noch eine Component, mit der wir die zu bewegenden Entitäten markieren können.

import core.System;
import core.Component;

public class WalkerComponent implements Component {}


public class WalkerSystem extends System {
    public WalkerSystem() {
        super(WalkerComponent.class);
    }

    @Override
    public void execute() {
        entityStream().forEach(e -> {
            if (e.isPresent(WalkerComponent.class)) {
                VelocityComponent vc = e.fetch(VelocityComponent.class).get();
                vc.currentXVelocity(vc.xVelocity());
                vc.currentYVelocity(vc.yVelocity());
            }
        });
    }
}


public class Main {
    public static void main(String... args) {

        // Add some one-time configuration
        Game.userOnSetup( ... );


        // Create a new monster in every new level
        Game.userOnLevelLoad(first -> {
            Entity fb = new Entity("HUGO");

            ...

            fb.add(new WalkerComponent());

            Game.add(fb);
        });


        // Register our new system
        Game.add(new WalkerSystem());

        // Start the game loop
        Game.run();
    }
}

Die neue Component (WalkerComponent) ist einfach eine leere Klasse, die von core.Component erbt. Wir brauchen keine Werte o.ä., die wir hier ablegen wollen - eine leere Klasse reicht für das Beispiel. Dem neuen Monster geben wir diese neue Component nun mit.

Das WalkerSystem wird auch etwas ergänzt: Im Konstruktor rufen wir den Super-Konstruktor auf und übergeben die WalkerComponent-Klasse - dies ist die Component, für die sich das System interessiert. Zusätzlich legen wir noch eine if-Abfrage um das Aktualisieren der aktuellen Geschwindigkeit: Der Block soll nur dann ausgeführt werden, wenn die im aktuellen Schleifendurchlauf gerade betrachtete Entität eine WalkerComponent hat.

Nun läuft nur das neue Monster automatisch, der Held bleibt stehen und reagiert erst auf Tastendrücke. Prima!

Auf diese Weise können Sie beispielsweise den Monstern einen Gesundheitszustand geben und diese bei zu schlechter Gesundheit "sterben" lassen (aus dem Spiel entfernen). Sie könnten aber auch komplexere Dinge wie die Kollision zwischen zwei Entitäten realisieren.

Tatsächlich gibt es im Sub-Projekt "dungeon" (Package contrib) bereits eine Vielzahl an Components und passenden Systems, die solche typischen Aufgaben bereits realisieren.

Kämpfe wie ein NPC

Wir haben beim Hero über das PlayerComponent eine Reaktion auf Tastatureingaben implementiert. Hier könnte man einer Taste auch den Start einer neuen Entität zuordnen, die sich dann automatisch bewegt. Man könnte also Feuerbälle schleudern ...

public class Main {
    public static void main(String... args) {


        // Add some one-time configuration
        Game.userOnSetup( () -> {
            Entity hero = new Entity("Hero");

            ...

            PlayerComponent pc = new PlayerComponent();
            pc.registerCallback(KeyboardConfig.FIRST_SKILL.value(), entity -> {
                Entity fb = new Entity("Fireball");

                fb.add(new PositionComponent(entity.fetch(PositionComponent.class).get().position()));

                try {
                    fb.add(new DrawComponent(new SimpleIPath("character/knight")));
                } catch (IOException e) {
                    System.err.println("Could not load textures for fireball.");
                    throw new RuntimeException(e);
                }

                fb.add(new VelocityComponent(2f, 2f));

                fb.add(new WalkerComponent());

                Game.add(fb);
            }, false);

            Game.add(hero);
        });


        // Create a new monster in every new level
        Game.userOnLevelLoad( ... );

        // Register our new system
        Game.add(new WalkerSystem());

        // Start the game loop
        Game.run();
    }
}

Wir registrieren einfach die Taste FIRST_SKILL (das ist ein "Q") in der PlayerComponent. Im hinterlegten Lamda-Ausdruck wird eine neue Entität erzeugt mit einer WalkerComponent, also ganz analog zu dem neuen Monster vorhin beim Laden eines neuen Levels. Zusätzlich wird hier noch ein dritter Parameter mit dem Wert false mitgegeben: Die PlayerComponent wird in jedem Frame ausgewertet - wenn die Taste "Q" also über mehrere Frames hinweg gedrückt ist (was sehr wahrscheinlich ist), würde in jedem dieser Frames je eine neue Entität erzeugt und losgeschickt. Über diesen dritten Parameter können wir steuern, dass genau das nicht passiert. Man muss also die Taste "Q" zunächst wieder loslassen und dann erneut drücken, um noch einen Feuerball zu erzeugen und auf den Weg zu schicken. Als Textur habe ich einfach die im Sub-Projekt "game" vorhandene Textur für die Heros genommen - im Sub-Projekt "dungeon" gibt es dagegen auch Feuerbälle u.ä., aber dann müsste die Klasse auch in dieses Sub-Projekt umgezogen werden.

Unser Feuerball kann leider nichts, außer sich automatisch zu bewegen. Man könnte nun noch ein CollisionSystem entwickeln, welches Entitäten immer paarweise auf ihre Positionen vergleicht und eine Kollision feststellt, wenn sich die Entitäten zu nah kommen und diese Information in einer CollisionComponent speichern (wer mit wem und wann). Dann könnte man noch ein HealthSystem bauen, welches eine HealthComponent aktualisiert. Zusätzlich könnte man ein FightSystem schreiben, welches bei einer Kollision der getroffenen Entität (zufälligen?) Schaden zufügt, also die Werte in ihrer HealthComponent reduziert. (Alternativ könnte das CollisionSystem bei Kollision einen in der CollisionComponent gespeicherten Lambda-Ausdruck ausführen.) ... Die einzelnen Klassen interagieren also nicht direkt miteinander, sondern immer über den Umweg der Systems und Components.

All diese (und viele weitere) Components und Systems gibt es bereits im Package contrib im Sub-Projekt "dungeon".

Wrap-Up

Damit endet der kurze Ausflug in den Dungeon.

In einem ECS haben wir Entities, Components und Systems.

  • Die Entitäten sind nur Hüllen und gruppieren verschiedene Components.
  • In diesen Components werden die Werte für die jeweiligen Zustände gehalten.
  • Die Systems werden in jedem Durchlauf der Game-Loop aufgerufen und führen dabei ihre execute()-Methode aus. Typischerweise iterieren die Systeme dabei über alle Entitäten und verändern die Components der Entitäten.

Denken Sie daran, dass alles in einer Game-Loop läuft, die 30x oder 60x pro Sekunde aufgerufen wird. Sie können in der Regel keine direkte Interaktion zwischen verschiedenen Objekten realisieren, sondern müssen immer den Weg über die Systems gehen.

Schauen Sie gern in die vorhandenen Klassen und Packages und in die Dokumentation hinein:

  • Klassen in game/src/ und dungeon/src
  • Dokumentation unter game/doc/ und dungeon/doc/

Die Javadoc-Kommentare sollten Ihnen erste Ideen zur Funktionsweise geben (auch wenn für das angestrebte Ideal noch einiges an Arbeit notwendig ist). Schauen Sie gern die Dokumentation unter game/doc/ und dungeon/doc/ an, die im Laufe des Semesters schrittweise weiter wachsen wird.

Anregungen für Spielideen können Sie beispielsweise in den folgenden Videos finden:

Viel Spass im PM-Dungeon!