Skip to content

Stateful Animation1

Dies ist ein Tutorial zur Klasse StatefulAnimation. In diesem Tutorial:

  • Konzipierst du eine komplexe Spielfigur mit Zustandsübergängen.
  • Implementierst du funktionale Bewegungsmechanik für einen Platformer.
  • Setzt eine komplexe Spielfigur bestehend aus mehreren Animationen in einer Spielumgebung zusammen.

Stateful Animations

Die StatefulAnimation ist eine elegante Möglichkeit, komplexe Spielfiguren mit wenig Aufwand umzusetzen.

Nehmen wir dieses Beispiel:2

Zustand Animiertes GIF
Idle
Jumping
Midair
Falling
Landing
Walking
Running

Zustandsübergangsdiagramm für die Figur

Bevor die Umsetzung beginnt, ist es sinnvoll, die Zustände und deren Übergänge zu modellieren. Hier ist ein mögliches Zustandsübergangsdiagramm für die Figur.

Zustandsübergangsdiagramm für die Figur

Die Zustände als Enumeration

Zustände einer Figur werden in der Engine stets als enum implementiert.

Diese enum definiert die Spielerzustände und speichert gleichzeitig die Dateipfade der zugehörigen GIF-Dateien.

Zum Java-Code: demos/tutorials/stateful_animation/PlayerState.java

public enum PlayerState
{
    IDLE("idle"), WALKING("walk"), RUNNING("run"), JUMPING("jump_1up"),
    MIDAIR("jump_2midair"), FALLING("jump_3down"), LANDING("jump_4land");

    private String filename;

    PlayerState(String filename)
    {
        this.filename = filename;
    }

    public String getGifFileLocation()
    {
        return "traveler/" + filename + ".gif";
    }
}

Ist beispielsweise das GIF des Zustandes JUMPING gefragt, so ist es jederzeit mit JUMPING.getGifFileLocation() erreichbar. Dies macht den Code deutlich wartbarer.

Die Klasse für den Player Character

Mit den definierten Zuständen in PlayerState kann nun die Implementierung der eigentlichen Spielfigur beginnen:

Zum Java-Code: demos/tutorials/stateful_animation/StatefulPlayerCharacter.java

public class StatefulPlayerCharacter extends StatefulAnimation<PlayerState>
{

    public StatefulPlayerCharacter()
    {
        // Alle Bilder haben die Abmessung 64x64px und deshalb die gleiche Breite
        // und Höhe. Wir verwenden drei Meter.
        super(3, 3);
        setupPlayerStates();
        setupAutomaticTransitions();
        setupPhysics();
    }

    private void setupPlayerStates()
    {
        for (PlayerState state : PlayerState.values())
        {
            Animation animationOfState = Animation
                    .createFromAnimatedGif(state.getGifFileLocation(), 3, 3);
            addState(state, animationOfState);
        }
    }

    private void setupAutomaticTransitions()
    {
        setStateTransition(PlayerState.MIDAIR, PlayerState.FALLING);
        setStateTransition(PlayerState.LANDING, PlayerState.IDLE);
    }

    private void setupPhysics()
    {
        makeDynamic();
        setRotationLocked(true);
        setElasticity(0);
        setFriction(30);
        setLinearDamping(.3);
    }
}

In setupPlayerStates() werden alle in PlayerState definierten Zustände der Spielfigur eingepflegt, inklusive des Einladens der animierten GIFs.

Zwei der Zustände bestehen nur aus einen Animationszyklus. Danach sollen sie in einen anderen Zustand übergehen: MIDAIR geht über zu FALLING und LANDING geht über zu IDLE. Diese Übergänge können direkt über die Methode setStateTransition(...) umgesetzt werden.

Schließlich wird in setupPhysics() die Figur über die Engine-Physik noch dynamisch gesetzt und bereit gemacht, sich als Platformer-Figur der Schwerkraft auszusetzen. Der hohe Reibungswert setFriction(30) sorgt dafür, dass die Figur später schnell auf dem Boden abbremsen kann, sobald sie sich nicht mehr bewegt. Ein Verhalten, dass bei den meisten Platformern erwünscht ist.

Testbed

Damit die Figur getestet werden kann, schreiben wir ein schnelles Testbett für sie. In einer Scene bekommt sie einen Boden zum Laufen:

Der Zwischenstand: Noch passiert nicht viel.

Zum Java-Code: demos/tutorials/stateful_animation/StatefulAnimationDemo.java

public class StatefulAnimationDemo extends Scene
{
    public StatefulAnimationDemo()
    {
        StatefulPlayerCharacter character = new StatefulPlayerCharacter();
        setupGround();
        add(character);
        setFocus(character);
        setGravityOfEarth();
    }

    private void setupGround()
    {
        Rectangle ground = makePlatform(200, 0.2);
        ground.setCenter(0, -5);
        ground.setColor(new Color(255, 195, 150));
        makePlatform(12, 0.3).setCenter(16, -1);
        makePlatform(7, 0.3).setCenter(25, 2);
        makePlatform(20, 0.3).setCenter(35, 6);
        makeBall(5).setCenter(15, 3);
    }


    public static void main(String[] args)
    {
        Game.start(1200, 820, new StatefulAnimationDemo());
    }
}

Die Figur bleibt im IDLE-Zustand hängen. Nun gilt es, die übrigen Zustandsübergänge zu implementieren.

Implementieren der Zustände & Übergänge

Springen

Wir fokussieren uns nun auf die Übergänge zum Springen.

Auf Tastendruck (Leertaste) soll die Spielfigur springen, wenn sie auf festem Boden steht. Die Spielfigur implementiert nun zusätzlich den KeyStrokeListener und führt auf Leertastendruck die Sprungroutine aus:

Die Figur kann springen, aber nicht landen.

Quellcode: demos/stateful_animation/StatefulPlayerCharacter.java#L92-L104

Zum Java-Code: demos/tutorials/stateful_animation/StatefulPlayerCharacter.java

private void attemptJump()
{
    PlayerState state = getCurrentState();
    if (state == PlayerState.IDLE || state == PlayerState.WALKING
            || state == PlayerState.RUNNING)
    {
        if (isGrounded())
        {
            applyImpulse(new Vector(0, JUMP_IMPULSE));
            setState(PlayerState.JUMPING);
        }
    }
}

Fallen und Landen

Die nächsten Übergänge, die wir umsetzen, sind für das Fallen und Landen.

Als nächstes sorgen wir dafür, dass die Figur landen kann und schließlich zurück in den IDLE-Zustand kommt. Dafür ist die Geschwindigkeit der Figur in Y-Richtung wichtig. Im Zustandsübergangsdiagramm haben wir dafür v_y < 0 als Fallen definiert und v_y = 0 als Stehen. Das ist im Modell in Ordnung, allerdings ist die Physik mit Fließkomma-Zahlen nicht ideal für „harte“ Schwellwerte. Stattdessen definieren wir einen Grenzwert, innerhalb dessen wir auf 0 runden. (private static final float THRESHOLD = 0.01;).

Unsere Spielfigur soll in jedem Einzelbild ihre eigene Y-Geschwidingkeit überprüfen. Dazu implementiert sie nun zusätzlich FrameUpdateListener und prüft in jedem Frame entsprechend unseres Zustandsübergangsdiagrammes:

Die Figur hat jetzt einen vollen Sprungzyklus

Zum Java-Code: demos/tutorials/stateful_animation/StatefulPlayerCharacter.java

@Override
    public void onFrameUpdate(double dT)
    {
        Vector velocity = getVelocity();
        PlayerState state = getCurrentState();
        if (velocity.getY() < -THRESHOLD)
        {
            switch (state)
            {
            case JUMPING:
                setState(PlayerState.MIDAIR);
                break;

            case IDLE:
            case WALKING:
            case RUNNING:
                setState(PlayerState.FALLING);
                break;

            default:
                break;
            }
        }
        else if (velocity.getY() < THRESHOLD && state == PlayerState.FALLING)
        {
            setState(PlayerState.LANDING);
        }

    }

Player Movement

Die letzten zu implementierenden Zustände sind die Bewegung des Spielers. Durch die Physik-Engine gibt es viele Möglichkeiten, Bewegung im Spiel zu simulieren. Ein physikalisch korrekte Implementierung ist die kontinuierliche Anwendung einer Bewegungskraft:

Player Movement

Die (je nach Tastendruck gerichtete) Kraft beschleunigt die Spielfigur, bis die Reibung die wirkende Kraft ausgleicht. In der Methode setupPhysics() wurden bereits folgende Reibung für die Figur aktiviert:

  • Luftreibung (gesetzt mit setLinearDamping(.3))
  • Kontaktreibung, z. B mit Platformen (gesetzt mit setFriction(30))

Die Maximalgeschwindigkeit sowie die konstant wirkende Kraft setzen wir als Konstanten in der Klasse der Figur, um diese Werte schnell ändern zu können:

Zum Java-Code: demos/tutorials/stateful_animation/StatefulPlayerCharacter.java

private static final Float MAX_SPEED = 20;
private static final float FORCE = 16000;

Um die Kraft und die Geschwindigkeit frameweise zu implementieren, wird die Methode onFrameUpdate(double pastTime) erweitert:

Die Figur kann sich bewegen, jedoch resultiert dies noch nicht in Zustandsänderung.

Zum Java-Code: demos/tutorials/stateful_animation/StatefulPlayerCharacter.java

//In: onFrameUpdate(double pastTime)

if (Math.abs(velocity.getX()) > MAX_SPEED)
{
    setVelocity(new Vector(Math.signum(velocity.getX()) * MAX_SPEED,
            velocity.getY()));
}
if (Game.isKeyPressed(KeyEvent.VK_A))
{
    applyForce(new Vector(-FORCE, 0));
}
else if (Game.isKeyPressed(KeyEvent.VK_D))
{
    applyForce(new Vector(FORCE, 0));
}

Die Übergänge IDLE - WALKING - RUNNING

Die letzten zu implementierenden Zustandsübergänge hängen von der Spielerbewegung ab.

Die Figur kann jetzt voll gesteuert werden. Die Zustände WALKING und RUNNING können nun eingebracht werden. Ist die Figur in einem der drei „bodenständigen“ Zustände (IDLE, WALKING, RUNNING), so hängt der Übergang zwischen diesen Zuständen nur vom Betrag ihrer Geschindigkeit ab:

  • Bewegt sich die Figur „langsam“, so ist sie WALKING.
  • Bewegt sich die Figur „schnell“, so ist sie RUNNING.
  • Bewegt sich die Figur „gar nicht“, so ist sie IDLE.

Um die Begriffe „langsam“ und „schnell“ greifbar zu machen, ist einen Grenzwert nötig. Dazu definieren wir Konstanten in der Figur:

Zum Java-Code: demos/tutorials/stateful_animation/StatefulPlayerCharacter.java

private static final double RUNNING_THRESHOLD = 10;

private static final double WALKING_THRESHOLD = 1;

Sobald sich die Figur mindestens 1 Meter pro Sekunde bewegt, zählt sie als WALKING, sobald sie sich mindestens 10 Meter pro Sekunde bewegt (die Hälfte der maximalen Geschwindigkeit), so zählt sie als RUNNING.

Auf diese Grenzwerte wird jeden Frame in der onFrameUpdate(...) der Spielfigur geprüft, genauso wie zuvor die Y-Geschwindigkeit implementiert wurde. Damit ist die neue onFrameUpdate(...):

Die Figur ist mit ihren Zuständen und Übergängen vollständig implementiert.

Zum Java-Code: demos/tutorials/stateful_animation/StatefulPlayerCharacter.java

@Override
public void onFrameUpdate(double dT)
{
    Vector velocity = getVelocity();
    PlayerState state = getCurrentState();
    if (velocity.getY() < -THRESHOLD)
    {
        switch (state)
        {
        case JUMPING:
            setState(PlayerState.MIDAIR);
            break;

        case IDLE:
        case WALKING:
        case RUNNING:
            setState(PlayerState.FALLING);
            break;

        default:
            break;
        }
    }
    else if (velocity.getY() < THRESHOLD && state == PlayerState.FALLING)
    {
        setState(PlayerState.LANDING);
    }
    if (Math.abs(velocity.getX()) > MAX_SPEED)
    {
        setVelocity(new Vector(Math.signum(velocity.getX()) * MAX_SPEED,
                velocity.getY()));
    }
    if (Game.isKeyPressed(KeyEvent.VK_A))
    {
        applyForce(new Vector(-FORCE, 0));
    }
    else if (Game.isKeyPressed(KeyEvent.VK_D))
    {
        applyForce(new Vector(FORCE, 0));
    }
    if (state == PlayerState.IDLE || state == PlayerState.WALKING
            || state == PlayerState.RUNNING)
    {
        double velXTotal = Math.abs(velocity.getX());
        if (velXTotal > RUNNING_THRESHOLD)
        {
            changeState(PlayerState.RUNNING);
        }
        else if (velXTotal > WALKING_THRESHOLD)
        {
            changeState(PlayerState.WALKING);
        }
        else
        {
            changeState(PlayerState.IDLE);
        }
    }
    if (velocity.getX() > 0)
    {
        setFlipHorizontal(false);
    }
    else if (velocity.getX() < 0)
    {
        setFlipHorizontal(true);
    }
}

Die letzte Überprüfung der X-Geschwindigkeit dient dazu, die Bewegungsrichtung festzustellen. Mit dieser Info kann zum richtigen Zeitpunkt über setFlipHorizontal(boolean flip) die Blickrichtung der Figur angepasst werden.

Anregung zum Experimentieren

  • Different Settings, Different Game: Platformer werden fundamental anders, wenn du an den Stellschrauben drehst: Ändere die Werte für Beschleunigung, Entschleunigung, und Geschwindigkeit und überlege dir interessante Herausforderungen. Ein Platformer mit langer Be-/Ent-Schleunigung eignet sich weniger für viele präzise Sprünge, verlangt allerdings viel Überlegung und Vorbereitung von Seiten des Spielers. Spiele mit den Werten und ändere das Testbett und finde heraus, was dir Spaß macht.
  • Still too simple: Die Geschwindigkeit wird derzeit "blind" interpoliert: Sollte unsere Figur gegen eine Wand knallen, so wird die Geschwindigkeit im folgenden Frame gleich wieder auf den gewünschten Laufwert gesetzt. Durch smartes Reagieren auf Kollisionstests lässt sich die Figur in ihrer Bewegung weiter verbessern.
  • Create Something! Die Grundlage für einen Platformer ist geschaffen. Bewegung ist da. Allerdings sonst noch nicht viel. Baue ein, worauf du Lust hast, zum Beispiel:
  • Ein Level: Stelle Platformen zusammen, baue Schluchten, Kletterparcours nach oben, was immer dein Jump n' Run Herz begehrt!
  • Kamera-Einbindung: Die Kamera kann sich dem Charakter anpassen, sodass ein Level auch über die Sichtweite des Spielfensters hinaus ragen darf.
  • Pick-Ups: Bei Berührung erhält der Charakter einen Bonus (z.B. zeitweise höhere Geschwindigkeit/Sprungkraft)
  • Gegner: Andere Akteure, die der Charakter besser nicht berühren sollte; sie ziehen ihm Hit Points ab (oder beenden das Spiel direkt). Vielleicht kann sich der Charakter mit einem Mario-Sprung auf den Kopf der Gegner zur Wehr setzen?
  • Ein Ziel: Quo Vadis? Was ist das Ziel des Levels? Von Flagge am rechten Levelrand über Bossgegner und Collectibles ist alles möglich.
  • etc, etc, etc.

  1. Der Abschnitt stammt aus dem Engine-Alpha-Wiki: engine-alpha.org/wiki/v4.x/Stateful_Animation 

  2. Die Spielfigur stammt aus dem Open Pixel Project, aus dem Ordner ./sprites/humans/traveler/ der Zip-Datei opp-assets.zip