Skip to content

StatefulAnimation (Zustandsbehaftete Animation)1

Die Klasse StatefulAnimation ist im Paket pi.actor enthalten und kann über die Anweisung import pi.actor.StatefulAnimation; importiert werden.

Mit der Figur StatefulAnimation lassen sich komplexe Spielfiguren mit wenig Aufwand umsetzen.

Nehmen wir dieses Beispiel:2

Zustand Animiertes GIF
Idle
Jumping
Midair
Falling
Landing
Walking
Running

Zustandsübergangsdiagramm für die Figur

Bevor mit der Umsetzung begonnen wird, 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 Aufzählungstyp (Enumeration)

Die Zustände einer Figur werden in der Engine stets als Aufzählungstyp (enum) implementiert. Dieser Aufzählungstyp definiert die Spielerzustände und speichert gleichzeitig die Dateipfade der zugehörigen GIF-Dateien.

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

    private final String filename;

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

    @Getter
    public String gifFileLocation()
    {
        return "openpixelproject/sprites/humans/traveler/spr_m_traveler_"
                + filename + "_anim.gif";
    }
}
Zum Java-Code: demos/subprojects/demos/src/main/java/demos/docs/main_classes/actor/stateful_animation/PlayerState.java

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

Die Klasse der Spielfigur

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

public class StatefulPlayerCharacter extends StatefulAnimation<PlayerState>
        implements KeyStrokeListener, FrameUpdateListener
{
    private static final double THRESHOLD = 0.01;

    private static final double RUNNING_THRESHOLD = 10;

    private static final double WALKING_THRESHOLD = 1;

    private static final double MAX_SPEED = 20;

    private static final double FORCE = 16000;

    private static final double JUMP_IMPULSE = 1100;

    private Text text;

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

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

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

    private void setupPhysics()
    {
        makeDynamic();
        rotationLocked(true);
        restitution(0);
        friction(30);
        linearDamping(0.3);
    }
Zum Java-Code: demos/subprojects/demos/src/main/java/demos/docs/main_classes/actor/stateful_animation/StatefulPlayerCharacter.java

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 stateTransition() 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 friction(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.

Einbetten in eine Szene

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.

public class StatefulAnimationDemo extends Scene
{
    public StatefulAnimationDemo()
    {
        Text text = new Text("State");
        text.anchor(-10, 5);
        text.makeStatic();
        add(text);
        StatefulPlayerCharacter character = new StatefulPlayerCharacter(text);
        setupGround();
        add(character);
        focus(character);
        gravityOfEarth();
    }

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

    /**
     * Erstellt eine statische Plattform als Rechteck mit definierten Rändern
     * und Kollisionseigenschaften.
     *
     * Die Plattform besteht aus vier Fixture-Komponenten:
     * <ul>
     *
     * <li>Oberkante: Mit Reibung (0.2) und ohne Rückprallverhalten für
     * interaktive Oberfläche</li>
     *
     * <li>Linke Kante: Ohne Reibung und ohne Rückprallverhalten</li>
     *
     * <li>Rechte Kante: Ohne Reibung und ohne Rückprallverhalten</li>
     *
     * <li>Unterkante: Ohne Reibung und ohne Rückprallverhalten</li>
     * </ul>
     *
     * @param width Die Breite der Plattform in Meter.
     * @param height Die Höhe der Plattform in Meter.
     *
     * @return Das erstellte und der Szene hinzugefügte statische
     *     Rechteck-Objekt
     */
    private Rectangle makePlatform(double width, double height)
    {
        Rectangle rectangle = new Rectangle(width, height);
        rectangle.color(new Color(0, 194, 255));
        rectangle.makeStatic();
        ArrayList<FixtureData> platformFixtures = new ArrayList<>();
        FixtureData top = new FixtureData(FixtureBuilder
            .axisParallelRectangular(0.1, height - 0.1, width - 0.2, 0.1));
        top.setFriction(0.2);
        top.setRestitution(0);
        FixtureData left = new FixtureData(
                FixtureBuilder.axisParallelRectangular(0, 0, 0.1, height));
        left.setFriction(0);
        left.setRestitution(0);
        FixtureData right = new FixtureData(FixtureBuilder
            .axisParallelRectangular(width - 0.1, 0, 0.1, height));
        right.setFriction(0);
        right.setRestitution(0);
        FixtureData bottom = new FixtureData(
                FixtureBuilder.axisParallelRectangular(0, 0, width, 0.1));
        bottom.setFriction(0);
        bottom.setRestitution(0);
        platformFixtures.add(top);
        platformFixtures.add(left);
        platformFixtures.add(right);
        platformFixtures.add(bottom);
        rectangle.fixtures(() -> platformFixtures);
        add(rectangle);
        return rectangle;
    }

    private Circle makeBall(double d)
    {
        Circle circle = new Circle(d);
        circle.makeDynamic();
        add(circle);
        return circle;
    }

    public static void main(String[] args)
    {
        Controller.instantMode(false);
        Controller.start(new StatefulAnimationDemo(), 1200, 820);
    }
}
Zum Java-Code: demos/subprojects/demos/src/main/java/demos/docs/main_classes/actor/stateful_animation/StatefulAnimationDemo.java

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

    @Override
    public void onFrameUpdate(double pastTime)
    {
        Vector velocity = velocity();
        PlayerState state = state();
        text.content(state);
        if (velocity.y() < -THRESHOLD)
        {
            switch (state)
            {
            case JUMPING:
                state(PlayerState.MIDAIR);
                break;

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

            default:
                break;
            }
        }
        else if (velocity.y() < THRESHOLD && state == PlayerState.FALLING)
        {
            state(PlayerState.LANDING);
        }
Zum Java-Code: demos/subprojects/demos/src/main/java/demos/docs/main_classes/actor/stateful_animation/StatefulPlayerCharacter.java

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 Plattformen (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:

    private static final double MAX_SPEED = 20;

    private static final double FORCE = 16000;
Zum Java-Code: demos/subprojects/demos/src/main/java/demos/docs/main_classes/actor/stateful_animation/StatefulPlayerCharacter.java

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.

In der Methode onFrameUpdate():

        if (Math.abs(velocity.x()) > MAX_SPEED)
        {
            velocity(new Vector(Math.signum(velocity.x()) * MAX_SPEED,
                    velocity.y()));
        }
        if (Controller.isKeyPressed(KeyEvent.VK_A))
        {
            applyForce(new Vector(-FORCE, 0));
        }
        else if (Controller.isKeyPressed(KeyEvent.VK_D))
        {
            applyForce(new Vector(FORCE, 0));
        }
Zum Java-Code: demos/subprojects/demos/src/main/java/demos/docs/main_classes/actor/stateful_animation/StatefulPlayerCharacter.java

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:

    private static final double RUNNING_THRESHOLD = 10;

    private static final double WALKING_THRESHOLD = 1;
Zum Java-Code: demos/subprojects/demos/src/main/java/demos/docs/main_classes/actor/stateful_animation/StatefulPlayerCharacter.java

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.

    @Override
    public void onFrameUpdate(double pastTime)
    {
        Vector velocity = velocity();
        PlayerState state = state();
        text.content(state);
        if (velocity.y() < -THRESHOLD)
        {
            switch (state)
            {
            case JUMPING:
                state(PlayerState.MIDAIR);
                break;

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

            default:
                break;
            }
        }
        else if (velocity.y() < THRESHOLD && state == PlayerState.FALLING)
        {
            state(PlayerState.LANDING);
        }
        if (Math.abs(velocity.x()) > MAX_SPEED)
        {
            velocity(new Vector(Math.signum(velocity.x()) * MAX_SPEED,
                    velocity.y()));
        }
        if (Controller.isKeyPressed(KeyEvent.VK_A))
        {
            applyForce(new Vector(-FORCE, 0));
        }
        else if (Controller.isKeyPressed(KeyEvent.VK_D))
        {
            applyForce(new Vector(FORCE, 0));
        }
        if (state == PlayerState.IDLE || state == PlayerState.WALKING
                || state == PlayerState.RUNNING)
        {
            double velXTotal = Math.abs(velocity.x());
            if (velXTotal > RUNNING_THRESHOLD)
            {
                changeState(PlayerState.RUNNING);
            }
            else if (velXTotal > WALKING_THRESHOLD)
            {
                changeState(PlayerState.WALKING);
            }
            else
            {
                changeState(PlayerState.IDLE);
            }
        }
        if (velocity.x() > 0)
        {
            flipHorizontal(false);
        }
        else if (velocity.x() < 0)
        {
            flipHorizontal(true);
        }
    }
Zum Java-Code: demos/subprojects/demos/src/main/java/demos/docs/main_classes/actor/stateful_animation/StatefulPlayerCharacter.java

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.


  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