K (Die Übergänge IDLE - WALKING - RUNNING)
(Die Übergänge IDLE - WALKING - RUNNING)
 
(13 dazwischenliegende Versionen desselben Benutzers werden nicht angezeigt)
Zeile 9: Zeile 9:
  
 
* Konzipierst du eine komplexe Spielfigur mit Zustandsübergängen.
 
* Konzipierst du eine komplexe Spielfigur mit Zustandsübergängen.
* Setzt die Spielfigur in einer simplen Demo um.
+
* Implementierst du funktionale Bewegungsmechanik für einen Platformer.
 +
* Setzt eine komplexe Spielfigur bestehend aus mehreren Animationen in einer Spielumgebung zusammen.
  
 
== Stateful Animations ==
 
== Stateful Animations ==
Zeile 132: Zeile 133:
 
         setRotationLocked(true);
 
         setRotationLocked(true);
 
         setRestitution(0);
 
         setRestitution(0);
 +
        setFriction(30);
 +
        setLinearDamping(.3f);
 
     }
 
     }
 
}
 
}
Zeile 151: Zeile 154:
 
Wir wissen bereits, dass zwei der Zustände nur einen Animationszyklus bestehen. Danach sollen sie in einen anderen Zustand übergehen: <code>MIDAIR</code> geht über zu <code>FALLING</code> und <code>LANDING</code> geht über zu <code>IDLE</code>. Diese Übergänge können direkt über die Methode [https://docs.engine-alpha.org/4.x/ea/actor/StatefulAnimation.html#setStateTransition-State-State- <code>setStateTransition(...)</code>] umgesetzt werden.
 
Wir wissen bereits, dass zwei der Zustände nur einen Animationszyklus bestehen. Danach sollen sie in einen anderen Zustand übergehen: <code>MIDAIR</code> geht über zu <code>FALLING</code> und <code>LANDING</code> geht über zu <code>IDLE</code>. Diese Übergänge können direkt über die Methode [https://docs.engine-alpha.org/4.x/ea/actor/StatefulAnimation.html#setStateTransition-State-State- <code>setStateTransition(...)</code>] umgesetzt werden.
  
Schließlich wird in <code>setupPhysics()</code> die Figur über die [[v4.x/Physics|Engine-Physik]] noch dynamisch gesetzt und bereit gemacht, sich als Platformer-Figur der Schwerkraft auszusetzen.
+
Schließlich wird in <code>setupPhysics()</code> die Figur über die [[v4.x/Physics|Engine-Physik]] noch dynamisch gesetzt und bereit gemacht, sich als Platformer-Figur der Schwerkraft auszusetzen. Der hohe Reibungswert <code>setFriction(30)</code> 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 ===
 
=== Testbed ===
Zeile 257: Zeile 260:
 
=== Player Movement ===
 
=== Player Movement ===
  
Die letzten zu implementierenden Zustände sind die Bewegung des Spielers. Durch die [[v4.x/Physics|Physik-Engine]] gibt es viele Möglichkeiten, Bewegung im Spiel zu simulieren. Ein physikalische Implementierung wäre zum Beispiel die regelmäßige Anwendung von Impulsen.
+
Die letzten zu implementierenden Zustände sind die Bewegung des Spielers. Durch die [[v4.x/Physics|Physik-Engine]] gibt es viele Möglichkeiten, Bewegung im Spiel zu simulieren. Ein physikalisch korrekte Implementierung ist die kontinuierliche Anwendung einer Bewegungskraft:
  
Allerdings ist die physikalisch "korrekte" Implementierung von Spielerbewegung '''ungeeignet für die meisten Jump n' Run Szenarien'''. Stattdessen macht es in der Regel mehr Sinn, die folgenden Paramater zu klären und entsprechend zu '''interpolieren''':
+
[[Datei:StatefulAnimation Player Movement.png|mini]]
  
* Maximale Geschwindigkeit des Charakters
+
Die (je nach Tastendruck gerichtete) Kraft beschleunigt die Spielfigur, bis die Reibung die wirkende Kraft ausgleicht. In der Methode <code>setupPhysics()</code> wurden bereits folgende Reibung für die Figur aktiviert:
* Anlaufzeit "von 0 auf 100": Zeitraum, in dem der Charakter vom Stillstand zur maximalen Geschwindigkeit beschleunigt
 
* Bremszeit: Zeitraum, in dem der Charakter von maximaler Geschwindigkeit wieder zum stehen kommt
 
  
Ich definiere diese Werte als Konstanten in der <code>StatefulPlayerCharacter</code>-Klasse:
+
* Luftreibung (gesetzt mit <code>setLinearDamping(.3f)</code>)
 +
* Kontaktreibung, z.B, mit Platformen (gesetzt mit <code>setFriction(30)</code>)
 +
 
 +
In der Regel wollen wir bei einem Platformer eine sehr bestimmte Maximalgeschwindigkeit (und die ist das Ergebnis von langem und intensivem Test und Herumspielen mit der Bewegung). Die Maximalgeschwindigkeit sowie die konstant wirkende Kraft setze ich als Konstanten in der Klasse meiner Figur, um diese Werte schnell ändern zu können:
  
 
<source lang="java">
 
<source lang="java">
 
private static final Float MAX_SPEED = 20f;
 
private static final Float MAX_SPEED = 20f;
private static final Float ACC_TIME = 1.5f;
+
private static final float FORCE = 16000;
private static final Float DEC_TIME = 0.8f;
 
 
</source>
 
</source>
  
Du kannst im Folgenden mit diesen Werten experimentieren bis du zufrieden bist mit dem Spielerlebnis.
+
Um die Kraft und die Geschwindigkeit frameweise zu implementieren, wird die Methode <code>onFrameUpdate(float dT)</code> erweitert:
 +
 
 +
[[Datei:StatefulAnimation Movement Base.gif|mini|Die Figur kann sich bewegen, jedoch resultiert dies noch nicht in Zustandsänderung.]]
  
Um möglichst wenig Aufwand bei der Interpolation der Spielergeschwindigkeit <code>v_x</code> zu haben, nutze ich die [[v4.x/Interpolation|Keyframe-Funktion der Engine]], die automatisch die Geschwindigkeit interpolieren kann. So muss ich mit KEYFRAMES nur die Zeit und den Endwert eingeben. Die folgenden Funktionen werden jeweils beim Drücken bzw. Loslassen der A/D-Tasten aufgerufen und realisieren die gesamte Bewegung:
+
<source lang="java">
 +
//In: onFrameUpdate( float dT )
  
[[Datei:StatefulAnimation Movement Base.gif|mini|Die Figur bewegt sich nun, aber die Zustände sind noch nicht umgesetzt.]]
+
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));
 +
}
 +
</source>
 +
 
 +
=== Die Übergänge IDLE - WALKING - RUNNING ===
 +
 
 +
[[Datei:TransitionDiagram vx states.png|mini|Die letzten zu implementierenden Zustandsübergänge hängen von der Spielerbewegung ab]]
 +
 
 +
Die Figur kann jetzt voll gesteuert werden. Die Zustände <code>WALKING</code> und <code>RUNNING</code> 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 <code>WALKING</code>.
 +
* Bewegt sich die Figur "schnell", so ist sie <code>RUNNING</code>.
 +
* Bewegt sich die Figur "gar nicht", so ist sie <code>IDLE</code>.
 +
 
 +
Um die Begriffe "langsam" und "schnell" greifbar zu machen, ist einen Grenzwert nötig. Dazu definiere ich Konstanten in der Figur:
 +
 
 +
<source lang="java">
 +
private static final float RUNNING_THRESHOLD = 10;
 +
private static final float WALKING_THRESHOLD = 1;
 +
</source>
 +
 
 +
Sobald sich die Figur mindestens 1 Meter/Sekunde bewegt, zählt sie als <code>WALKING</code>, sobald sie sich mindestens 10 Meter/Sekunde bewegt (die Hälfte der maximalen Geschwindigkeit), so zählt sie als <code>RUNNING</code>.
 +
 
 +
Auf diese Grenzwerte wird jeden Frame in der <code>onFrameUpdate(...)</code> der Spielfigur geprüft, genauso wie zuvor die Y-Geschwindigkeit implementiert wurde. Damit ist die neue <code>onFrameUpdate(...)</code>:
 +
 
 +
[[Datei:StatefulAnimation Movement Full.gif|mini|Die Figur ist mit ihren Zuständen und Übergängen vollständig implementiert.]]
  
 
<source lang="java">
 
<source lang="java">
private KeyFrames currentMovement = null;
+
@Override
 +
public void onFrameUpdate(float 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()));
 +
    }
  
private void initiateMovement(boolean toTheRight) {
+
    if(Game.isKeyPressed(KeyEvent.VK_A)) {
    KeyFrames movementSpeed = new KeyFrames((speed) -> setVelocity(new Vector(speed, getVelocity().getY())));
+
        applyForce(new Vector(-FORCE, 0));
     movementSpeed.addKeyframe(new KeyFrame<>(getVelocity().getX(), KeyFrame.Type.LINEAR, 0));
+
     } else if(Game.isKeyPressed(KeyEvent.VK_D)) {
    movementSpeed.addKeyframe(new KeyFrame<>(MAX_SPEED * (toTheRight ? 1 : -1), KeyFrame.Type.SMOOTHED_SIN, ACC_TIME));
+
        applyForce(new Vector(FORCE, 0));
    movementSpeed.setInifinite(true);
+
     }
     setNewMovement(movementSpeed);
 
}
 
  
private void stopMovement() {
+
    if(state == PlayerState.IDLE || state == PlayerState.WALKING || state == PlayerState.RUNNING) {
    if(Game.isKeyPressed(KeyEvent.VK_A) || Game.isKeyPressed(KeyEvent.VK_D)) {
+
        float velXTotal = Math.abs(velocity.getX());
         return;
+
        if(velXTotal > RUNNING_THRESHOLD) {
 +
            changeState(PlayerState.RUNNING);
 +
        } else if(velXTotal > WALKING_THRESHOLD) {
 +
            changeState(PlayerState.WALKING);
 +
         } else {
 +
            changeState(PlayerState.IDLE);
 +
        }
 
     }
 
     }
    KeyFrames movementSpeed = new KeyFrames((speed) -> setVelocity(new Vector(speed, getVelocity().getY())));
 
    movementSpeed.addKeyframe(new KeyFrame<>(getVelocity().getX(), KeyFrame.Type.SMOOTHED_SIN, 0));
 
    movementSpeed.addKeyframe(new KeyFrame<>(0f, KeyFrame.Type.SMOOTHED_SIN, DEC_TIME));
 
    movementSpeed.setInifinite(false);
 
    setNewMovement(movementSpeed);
 
}
 
  
private void setNewMovement(KeyFrames movementInterpolation) {
+
    if(velocity.getX() > 0) {
     if(currentMovement != null) {
+
        setFlipHorizontal(false);
         removeFrameUpdateListener(currentMovement);
+
     } else if(velocity.getX() < 0) {
 +
         setFlipHorizontal(true);
 
     }
 
     }
    currentMovement = movementInterpolation;
 
    addFrameUpdateListener(movementInterpolation);
 
 
}
 
}
 
</source>
 
</source>
  
Die Methode <code>initiateMovement(boolean toTheRight)</code> beginnt die Bewegung der Figur, entweder nach links (<code>initiateMovement(false)</code>) oder nach rechts (<code>initiateMovement(true)</code>). Sie interpoliert die X-Geschwindigkeit der Figur <code>v_x</code> linear von ihrem derzeitigen Wert bis zu <code>MAX_SPEED</code> innerhalb der Zeit <code>ACC_TIME</code>. Dabei ändert sie die Y-Geschwindigkeit nie. Nachdem die maximale Geschwindigkeit erreicht wird, sorgt die Interpolation bis auf Weiteres dafür, dass die Figur die maximale Geschwindigkeit beibehält.
+
Die letzte Überprüfung der X-Geschwindigkeit dient dazu, die Bewegungsrichtung festzustellen. Mit dieser Info kann zum richtigen Zeitpunkt über [https://docs.engine-alpha.org/4.x/ea/actor/StatefulAnimation.html#setFlipHorizontal-boolean- <code>setFlipHorizontal(boolean flip)</code>] die Blickrichtung der Figur angepasst werden.
  
Die Methode <code>stopMovement()</code> beendet die Bewegung der Figur, indem sie von der aktuellen X-Geschwindigkeit bis hin zu 0 "smooth" interpoliert (zunächst verliert die Figur wenig Geschwindigkeit, dann mehr, und schließlich wieder weniger). Dies passiert innerhalb der Zeit <code>DEC_TIME</code>. Nachdem die X-Geschwindigkeit auf 0 reduziert wurde, ist die Interpolation beendet. Diese Methode beginnt mit einer Abfrage, die sicherstellt, dass sich der Spieler nicht immernoch bewegen möchte (also ob mindestens eine der Tasten A oder D gedrückt ist). Ist dies der Fall, soll die Bewegung nicht gestoppt werden. Wenn du dir unsicher bist, warum die Abfrage zu beginn nötig ist, nimm sie raus, und sie was passiert.
+
== Anregung zum Experimentieren ==
  
=== Die Übergänge IDLE - WALKING - RUNNING ===
 
  
[[Datei:TransitionDiagram vx states.png|mini|Die letzten zu implementierenden Zustandsübergänge hängen von der Spielerbewegung ab]]
 
  
Die Figur kann jetzt voll gesteuert werden. Nur noch die letzten Zustandsübergänge stehen aus
+
* '''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 [[v4.x/Camera|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.

Aktuelle Version vom 6. Januar 2020, 16:41 Uhr


Dies ist ein weiterführendes Tutorial zur Engine Version 4.x. Du findest eine Übersicht über alle Tutorials hier.

Inhalt

Dies ist ein Tutorial zur ea.actor.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:

Zustand Animiertes GIF
Idle
spr m traveler idle anim.gif
Jumping
spr m traveler jump 1up anim.gif
Midair
spr m traveler jump 2midair anim.gif
Falling
spr m traveler jump 3down anim.gif
Landing
spr m traveler jump 4land anim.gif
Walking
spr m traveler walk anim.gif
Running
spr m traveler run anim.gif

Das sind viele zu jonglierende Zustände. Und für ein normales Platformer-Spiel ist die Anzahl an Zuständen eher gering.

Zum Nachimplementieren kannst du die animierten GIFs vom Wiki herunterladen.

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.

Tutorial State Transition Diagram.png


Implementieren der Figur

Nachdem nun ein guter Überblick über die Figur besteht, können wir zielgerichtet die Implementierung der Figur starten.

Die Zustände als Enumeration

Hierzu beginnen wir bei den Zuständen. 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.

public enum PlayerState {
    IDLE("spr_m_traveler_idle_anim.gif"),
    WALKING("spr_m_traveler_walk_anim.gif"),
    RUNNING("spr_m_traveler_run_anim.gif"),
    JUMPING("spr_m_traveler_jump_1up_anim.gif"),
    MIDAIR("spr_m_traveler_jump_2midair_anim.gif"),
    FALLING("spr_m_traveler_jump_3down_anim.gif"),
    LANDING("spr_m_traveler_jump_4land_anim.gif");

    private String gifFileName;

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

    public String getGifFileLocation() {
        return "eatutorials/statefulanimation/assets/" + this.gifFileName;
    }
}

Damit sind alle Zustände definiert. 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:

import ea.actor.Animation;
import ea.actor.BodyType;
import ea.actor.StatefulAnimation;

public class StatefulPlayerCharacter
extends StatefulAnimation<PlayerState> {

    public StatefulPlayerCharacter() {
        super(3, 3); //All GIFs are 64x64 px, hence: Same width/height. In this case: 3m each
        
        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() {
        setBodyType(BodyType.DYNAMIC);
        setRotationLocked(true);
        setRestitution(0);
        setFriction(30);
        setLinearDamping(.3f);
    }
}

In setupPlayerStates() werden alle in PlayerState definierten Zustände der Spielfigur eingepflegt, inklusive des Einladens der animierten GIFs. Hier wird der Vorteil der String-Variable im PlayerState deutlich: Der Code ist angenehm zu lesen. Im Vergleich hierzu der Code ohne die Variable:

private void setupPlayerStatesAlternative() {
    addState(PlayerState.IDLE, Animation.createFromAnimatedGif("eatutorials/statefulanimation/assets/spr_m_traveler_idle_anim.gif", 3, 3);
    addState(PlayerState.WALKING, Animation.createFromAnimatedGif("eatutorials/statefulanimation/assets/spr_m_traveler_walk_anim.gif", 3, 3);
    addState(PlayerState.RUNNING, Animation.createFromAnimatedGif("eatutorials/statefulanimation/assets/spr_m_traveler_run_anim.gif", 3, 3);
    addState(PlayerState.JUMPING, Animation.createFromAnimatedGif("eatutorials/statefulanimation/assets/spr_m_traveler_jump_1up_anim.gif", 3, 3);
    addState(PlayerState.FALLING, Animation.createFromAnimatedGif("eatutorials/statefulanimation/assets/spr_m_traveler_jump_3down_anim.gif", 3, 3);
    //etc.
}

Wir wissen bereits, dass zwei der Zustände nur einen Animationszyklus bestehen. 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.
import ea.Game;
import ea.Scene;
import ea.Vector;
import ea.actor.BodyType;
import ea.actor.Rectangle;

import java.awt.Color;

public class StatefulAnimationTestScene
extends Scene {

    public StatefulAnimationTestScene() {
        StatefulPlayerCharacter character = new StatefulPlayerCharacter();

        setupGround();
        add(character);

        setGravity(new Vector(0, -9.81f));
    }

    private void setupGround() {
        Rectangle ground = new Rectangle(200, 0.2f);
        ground.setCenter(0, -5);
        ground.setColor(new Color(255, 195, 150));
        ground.setBodyType(BodyType.STATIC);
        ground.setRestitution(0);
        add(ground);
    }

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


Damit können wir das Zwischenergebnis schonmal sehen. Und sehen noch nicht viel. 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

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

Die Figur kann springen, aber nicht landen.
private void attemptJump() {
    PlayerState state = getCurrentState();
    if(state == PlayerState.IDLE || state == PlayerState.WALKING || state == PlayerState.RUNNING) {
        if(isGrounded()) {
            applyImpulse(new Vector(0, 850));
            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. Ich habe dafür private static final float THRESHOLD = 0.01f; definiert. Es geht sicherlich noch genauer, aber das reicht für dieses Tutorial.

Unsere Spielfigur soll einfach in jedem Frame 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(float 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:

StatefulAnimation Player Movement.png

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(.3f))
  • Kontaktreibung, z.B, mit Platformen (gesetzt mit setFriction(30))

In der Regel wollen wir bei einem Platformer eine sehr bestimmte Maximalgeschwindigkeit (und die ist das Ergebnis von langem und intensivem Test und Herumspielen mit der Bewegung). Die Maximalgeschwindigkeit sowie die konstant wirkende Kraft setze ich als Konstanten in der Klasse meiner Figur, um diese Werte schnell ändern zu können:

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

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

Die Figur kann sich bewegen, jedoch resultiert dies noch nicht in Zustandsänderung.
//In: onFrameUpdate( float dT )

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 definiere ich Konstanten in der Figur:

private static final float RUNNING_THRESHOLD = 10;
private static final float WALKING_THRESHOLD = 1;

Sobald sich die Figur mindestens 1 Meter/Sekunde bewegt, zählt sie als WALKING, sobald sie sich mindestens 10 Meter/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(float 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) {
        float 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.