Android Game Tutorial Teil 2: Sprite Animation

Nachdem Du dich im ersten Teil mit dem Hintergrund und dessen Bewegung beschäftigt hast geht es im zweiten Teil darum, wie Du einen Sprite bewegen und steuern kannst.

Plane Added

Abbildung 13: Flugzeug in den Wolken

  • Teil 1 - Background Scrolling und Double Buffering
  • Teil 2 - Sprite Animation
  • Teil 3 - Head-up Display und Performance Optimierung

Auf den bewegten Hintergrund möchten platzierst Du jetzt einen Sprite in Form eines Flugzeuges platzieren. Der Hintergrund soll rotieren und der Sprite auf der Stelle stehenbleiben, dass es so aussieht als fliege das Flugzeug durch die Wolken.

3.1 Modellierung eines Flugzeuges

Für die Verwaltung des Flugzeuges verwendest Du die Klasse Plane, die Du im Projekt im Package com.predic8.tutorial.model findest. Die Klasse kapselt wie Abbildung 3 zeigt die Koordinaten sowie das Bild des Flugzeuges. Später könnte die Klasse Plane weitere Aufgaben, wie z.B. die Berechnung von Kollisionen übernehmen.

uml diagram for plane class

Abbildung 2: Die Klasse Plane

3.2 Anzeigen des Sprites

Die Anzeige des Sprites realisierst Du mit einem zusätzlichen View, diesen PlaneView findest Du im Package com.predic8.tutorials.views. Diesem übergibst Du das Flugzeug und sorgst in der onDraw-Methode des Views dafür, dass das Flugzeug an seinen x,y Koordinaten gezeichnet wird. Die onDraw-Methode musst Du aber noch um die markierte Zeile erweitern.

  @Override
  protected void onDraw(Canvas canvas) {
    canvas.drawBitmap(plane.getImage(), plane.getX(), plane.getY(), null);    
  }
      
Listing 1: Der View für das Flugzeug

Jetzt musst Du den PlaneView über den Hintergrund legen. Mit einem Framelayout sollen die Views für das Flugzeug und den Hintergrund deckungsgleich aufeinander positioniert werden. Erzeuge das Framelayout in der Main-Klasse, in der auch die übrigen Views verwaltet werden. Ergänze die markierten Zeilen in der onCreate-Methode der Main-Klasse

com.predic8.tutorial
@Override
protected void onCreate(Bundle savedInstanceState) {
  super.onCreate(savedInstanceState);

  bgv = new BackgroundView(this);
    
  FrameLayout fl = new FrameLayout(this);
  FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,ViewGroup.LayoutParams.MATCH_PARENT);
  fl.setLayoutParams(lp);
    
    
  PlaneView pv = new PlaneView(this, new Plane(0, 0,BitmapFactory.decodeResource(getResources(), R.drawable.plane_icon)), bgv.getBackgroundModel());
    
  fl.addView(bgv);
  fl.addView(pv);
    
  setContentView(fl);
}
      
Listing 2: onCreate Methode mit FrameLayout und PlaneView

Wird das Flugzeug nicht angezeigt, vergewissere Dich, dass Du in Zeile 17 den Parameter von bgv nach fl geändert hast.

Das ganze sollte dann in etwa so aussehen:

Plane Added

Abbildung 13: Flugzeug hinzugefügt

Das Flugzeug kann jetzt über einen scheinbar unendlichen Hintergrund fliegen.

3.3 Automatischen Ruhezustand deaktivieren

Vielleicht hast Du bereits bemerkt, dass Android nach einiger Zeit in den Ruhezustand übergeht, selbst bei laufender App. Das Wechseln in den Ruhezustand stört beim Spielen, deshalb erweitern wir die onResume-Methode der Main-Klasse um eine weitere Zeile, die verhindert dass die App in den Ruhezustand geht.

@Override
protected void onResume() {
  super.onResume();
  getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
  bgv.resume();
}
      
Listing 3: Deaktivierung des automatischen Ruhezustandes

4. Sprite Steuerung

Es gibt verschiedene Möglichkeiten einen Sprite über den Bildschirm zu steuern. Bei den Apps verbreitet sind Gesten auf dem Touchscreen und das Bewegen des Smartphones. Im Tutorial wird der Sprite über die Neigung des Smartphones gesteuert, da uns dies passender für die Steuerung eines Flugzeuges erschien.

4.1 Auslesen der Neigung des Gerätes

Um den Lagesensor verwenden zu können, benötigt man ein Objekt, welches Android mit Ereignissen über die Änderung, der Lage des Gerätes informieren kann. Ein solches Objekt muss das Interface SensorEventListener implementieren. Erweitere dazu die Main-Klasse um ein implements Statement.

public class Main extends Activity implements SensorEventListener{

Nach der Änderung wird Dir Eclipse eine Fehlermeldung anzeigen, da die Main-Klasse noch nicht die Methoden des Interfaces SensorEventListener implementiert.

Am einfachsten kannst Du den Fehler beheben, indem Du einen Rechtsklick auf Main ausführst und die Option Add unimplemented Methods auswählst um die fehlenden Methoden automatisch hinzuzufügen. Eclipse wird Dir die beiden Methoden onAccuracyChanged und onSensorChanged hinzufügen.

4.2 Vorbereitung der Neigungssteuerung

Jetzt musst Du die Daten aus dem Sensor auslesen, und an die entsprechenden Views bzw. Objekte weiterleiten.

Erweitere die Main-Klasse um die folgenden Instanzvariablen, um von allen Methoden auf diese zugreifen zu können.

  public class Main extends Activity implements SensorEventListener{

  private BackgroundView bgv;
  
  private SensorManager mSensorManager;
  private Sensor mAccelerometer;

  private PlaneView pv;
      
Listing 4: Instanzvariablen

Da pv nun zu einer Instanzvariablen werden soll, muss onCreate ebenfalls angepasst werden, da der PlaneView sonst nur in einer lokalen Variablen gespeichert wird. Vergisst Du diesen Schritt, wird dies später zu Nullpointer-Exceptions führen.

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    bgv = new BackgroundView(this);

    FrameLayout fl = new FrameLayout(this);
    FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,ViewGroup.LayoutParams.MATCH_PARENT);
    fl.setLayoutParams(lp);

    pv = new PlaneView(this, new Plane(0, 0,BitmapFactory.decodeResource(getResources(), R.drawable.plane_icon)), bgv.getBackgroundModel());

    fl.addView(bgv);
    fl.addView(pv);

    setContentView(fl);
  }
      
Listing 5: Zuweisen des PlaneViews zur Instanzvariablen

4.3 Erstellen des Accelerometers

Der Sensor ist ein System-Service, daher musst du diesen erst über den Context vom System holen und spezifizieren welchen Sensor Du betrachten möchtest. Für eine Steuerung des Flugzeuges über die Neigung des Telefons benötigst Du den Lagesensor für den es die Konstante Sensor.TYPE_ORIENTATION gibt. Erweitere die onCreate-Methode der Main-Klasse um die markierten Zeilen 5 und 6.

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    
    mSensorManager = (SensorManager) getSystemService(Context.SENSOR_SERVICE);
    mAccelerometer = mSensorManager.getDefaultSensor(Sensor.TYPE_ORIENTATION);

    bgv = new BackgroundView(this);
  ...
      
Listing 6: Holen des Sensor Service

4.4 Anbinden der Listener

Erweitere die Main-Klasse um die Methode getPlanePosition. Diese berechnet anhand des aktuellen Kippwineksl die Position des Flugzeugs auf dem Bildschirm.

  
        public float[] getPlanePosition(float y, float z, float maxAngle) {
          float[] coordinates = new float[2];

          coordinates[0] = (pv.getWidth()/2)+((y/maxAngle)*pv.getWidth()/2)-(pv.getPlane().getImage().getWidth()/2);
          coordinates[1] = (pv.getHeight()/2)+((z/maxAngle)*pv.getHeight()/2)-(pv.getPlane().getImage().getHeight()/2);

          return coordinates;
        }
      
Listing 7: Bestimmung der Flugzeugposition

Als nächstes benötigst du einen Listener, der die Daten des Sensors ausliest, und Dir diese zur Verfügung stellt. Den Listener registrieren wir im onResume-Callback der App. Dies geschieht bewusst nicht im onCreate, da Du den Listener im onPause Callback entferst. Du gibst damit den nicht mehr benötigten Sensor wieder frei. Operationen wie das Hinzufügen und entfernen von Listenern sollte immer symmetrisch durchgeführt werden. Siehe onCreate - onDestroy und onResume - onPause.

  @Override
  protected void onResume() {
    super.onResume();
    getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
    mSensorManager.registerListener(this, mAccelerometer, SensorManager.SENSOR_DELAY_NORMAL);
    bgv.resume();
  }

  @Override
  protected void onPause() {
    super.onPause();
    mSensorManager.unregisterListener(this);
    bgv.pause();
  }

  @Override
  public void onAccuracyChanged(Sensor sensor, int accuracy) {
    // kann für dieses Beispiel ignoriert werden!
  }

  @Override
  public void onSensorChanged(SensorEvent event) {
    
    if (event.sensor.getType() == Sensor.TYPE_ORIENTATION){
      float y = event.values[1];
      float z = event.values[2];

      float[] coords= getPlanePosition(y,z,90);

      pv.getPlane().setPosition(coords);
      pv.invalidate();
    }
  }
      
Listing 8: Callback Methoden für den Sensor

Starte jetzt die App und das Flugzeug über die Neigung deines Telefons. Die Steuerung funktioniert bereits hat aber noch ein paar Schwächen:

  • Du kannst das Flugzeug aus dem Bildschirm heraus fliegen lassen.
  • Das Phone muss sehr weit gekippt werden, um den äußeren Bildschirmrand zu erreichen.
  • Das Flugzeug bewegt sich mit einem merklichen Ruckeln.

4.5 Eliminierung des Ruckeln

Das Ruckeln des Flugzeuges kann zwei Gründe haben. Zum einen kann Dein Gerät zu alt sein und nicht über genügend Leistung verfügen um ein flüssiges Bild zu erzeugen. Bitte nicht gleich losziehen und das neuste Samsung Galaxy kaufen. Wir schöpfen zuerst alle Möglichkeiten aus, um auch auf älteren Geräten gute Ergebnisse zu erzielen. In der Tat läuft das fertige Spiel auf dem inzwischen betagten Samsung Galaxy I9000 recht gut.

Ein weiterer Grund ist die niedrige Abtastrate bzw. Genauigkeit des Lagesensors. Der Sensor steht auf SENSOR_DELAY_NORMAL, das heisst dass wir im 225ms Rhytmus neue Werte bekommen. Bei 225 Millisekunden entspricht das etwa 4 Positionsänderungen pro Sekunde. Nicht gerade berauschend für ein zeitgemäßes Spiel.

Android bietet aber die Möglichkeit die Abtastrate des Sensors zu erhöhen. Neben NORMAL gibt es auch die Modi UI(77ms), Game(38ms) und fastest(17ms). Für dieses Tutorial verwendest Du den Modus SENSOR_DELAY_GAME.

Tausche die Konstante für die Abtastrate des Sensors in der onResume-Methode aus.

  @Override
  protected void onResume() {
    super.onResume();
    getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
    mSensorManager.registerListener(this, mAccelerometer, SensorManager.SENSOR_DELAY_GAME);
    bgv.resume();
  }
      
Listing 9: Steigerung der Abtastrate

Die Animation des Flugzeugs sollte jetzt ruckelfrei sein. Auf älteren Geräten besteht die Möglichkeit, dass der Sensor nur Ganzzahlige Werte ausliest, und das Flugzeug daher weiterhin ein sprunghaftes Verhalten zeigt.

4.6 Verbesserung der Bedienbarkeit

Ein Kippen um 90° in alle Richtungen ist nicht gerade ergonomisch. Bei einem derart gekippten Telefon ist das Display kaum einzusehen und dadurch gehen Dir eventuell wichtige Informationen verloren. In der onSensorChanged-Methode legst Du daher einen maximalen Winkel fest und kontrollierst diesen mit zwei if-Statements:

    if (event.sensor.getType() == Sensor.TYPE_ORIENTATION){
      float y = event.values[1];
      float z = event.values[2];

      float maxAngle = 30;
      
      if (y > maxAngle)
        y = maxAngle;
      if (y < -maxAngle)
        y = -maxAngle;

      if (z > maxAngle)
        z = maxAngle;
      if (z < -maxAngle)
        z = -maxAngle;
      
Listing 10: Begrenzung des Kippwinkels

Die erhaltenen Kippwinkel musst Du noch in Koordinaten umrechnen, dies geschieht weiterhin über die Methode getPlanePosition, aber diese benötigt den aktuellen maxAngle da die berechneten Koordianten andernfalls nicht den kompletten Bildschirm abdecken. Die berechneten Koordinaten gibst Du dann an das Flugzeug weiter.

      getPlanePosition(y,z,maxAngle);

      pv.getPlane().setPosition(coords);
      pv.invalidate();
      
Listing 11: Setzen der neuen Koordinaten

Damit hast Du gleich zwei Probleme auf einmal gelöst. Es ist zum einen nicht mehr möglich das Flugzeug komplett vom Bildschirm verschwinden zu lassen und darüber hinaus schrumpft der maximal relevante Kippwinkel auf eine annehmbare Größe.

4.7 Validierung der Spriteposition

Da das Flugzeug aktuell noch teilweise aus dem Bild verschwinden kann, müssen wir uns Kriterien für eine sinnvolle Positionierung des Sprites überlegen. Da das Sprite durch ein Bitmap mit einer gegebenen Größe dargestellt wird, können wir für jede neue Position testen, ob es sich dabei um eine gültige Position handelt, wobei gültig bedeutet, dass die Bitmap komplett sichtbar ist. Definiere eine neue Instanzvariable coords in der Klasse PlaneView.

  private Background bg;
  
  private float[] coords = new float[2];

  public PlaneView(Context context) {
    super(context);
  }
   

Die Methode keepPlaneVisible erhält die aus den Sensoren ausgelesenen Koordinaten zur Validierung. Die Größe des Flugzeuges erhalten wir direkt von der sich im Flugzeug Objekt befindlichen Bitmap.

Da der Ursprung der Koordinaten eines Views in der oberen linken Ecke liegt und die Methode canvas.drawBitmap() die Koordinaten als die linke obere Ecke der zu zeichnenden Bitmap interpretiert, müssen wir dafür sorgen, dass zwischen den Flugzeug-Koordinaten und dem unteren sowie rechten Rand des Views genug Platz bleibt um die komplette Bitmap auf den View zu zeichnen.

Daraus folgt dass die maximale x-Koordinate der Breite des Views abzüglich der Breite der Bitmap entspricht. Analog dazu funktioniert die Koordinatenberechnung auf der vertikalen Achse.

  private void keepPlaneVisible(Plane plane) {
    int height = plane.getImage().getHeight();
    int width = plane.getImage().getWidth();
    float xPlane = plane.getX();
    float yPlane = plane.getY();
    
    if (xPlane < 0)
      coords[0] = 1;
    else if (xPlane > getWidth()-width)
      coords[0] = getWidth()-width;
    else
      coords[0] = xPlane;
    
    if (yPlane < 0)
      coords[1] = 1;
    else if (yPlane > getHeight()-height)
      coords[1] = getHeight()-height;
    else
      coords[1] = yPlane;

  }
      
Listing 12: Begrenzung auf sichtbare Koordinaten

Die gerade geschriebene Methode keepPlaneVisible rufst Du nun vor jeder Zeichenoperation auf. Da onDraw performant sein muss, erzeugt keepPlaneVisible nicht jedes Mal ein neues Array mit Koordinaten, sondern nutzt das beim Konstruieren der Klasse erzeugte Array.

  @Override
  protected void onDraw(Canvas canvas) {
    keepPlaneVisible(plane);
    canvas.drawBitmap(plane.getImage(), coords[0], coords[1], null);
      
Listing 13: Zeichnen des Flugzeugs

4.8 Optimierung der Nullstellung

[BILD MIT DEN ACHSEN]

Nun musst du das Gerät auf X und Y Achse in der Wage halten, möchtest Du das Flugzeug auf dem Display zentriert halten. Diese Position ist auf Dauer nicht optimal, da man das Gerät normalerweise leicht gekippt hält, um besser sehen zu können. Der optimale Kippwinkel beträgt etwa 45°.

Um den Nullpunkt zu verlegen korrigierst Du den Winkel einer Achse einfach um 45° in dem du 45 von der Z-Achse abziehst. Modifiziere dazu die onSensorChanged-Methode in der Klasse Main.

  public void onSensorChanged(SensorEvent event) {
    
    if (event.sensor.getType() == Sensor.TYPE_ORIENTATION){
      float y = event.values[1];
      float z = event.values[2]-45;
  }
      
Listing 14: Anpassung der Z-Achse

4.9 Invertieren der Steuerung

Sicherlich ist Dir bei den Tests aufgefallen, dass das Flugzeug auf einer Achse ungewöhnlich, bzw. anders als erwartet reagiert. Das Verhalten auf einer Achse ist genau umgekehrt zu dem was man hier erwartet hätte. Das Problem lässt sich beheben, indem Du den ausgelesenen y-Wert durch Voranstellen eines Minuszeichens invertierst. Modizifiere dazu die onSensorChanged-Methode.

      public void onSensorChanged(SensorEvent event) {
    
      if (event.sensor.getType() == Sensor.TYPE_ORIENTATION){
        float y = - event.values[1];
        float z = event.values[2]-45;
      }
      ...
      
Listing 15: Invertieren der Y-Achse

Zusammenfassung

In diesem Abschnitt hast Du gelernt wie Du einen Sprite anlegst, diesen darstellst, mit Hilfe des Lagesensors in Bewegung versetzt und die Steuerung ergonomischer gestaltest. Weiter geht es mit Teil 3.