Samstag, 8. August 2015

Wie bekommt man Werbebanner in einzelne Views einer ListView?

oder

Ein Experiment


Wie man Werbung als Banner oder als ganze Seite in ein (libGDX-)Projekt einfügen kann, habe ich bereits erklärt. Wie aber bekommt man Werbebanner in einzelne Views einer ListView?

Voraussetzung

Ich habe ein fertiges Projekt, in dem bereits eine voll funktionsfähige ListView enthalten ist.

FODMAP - deutsche Liste

Idee

In dieser ListView soll nun jede x. View ein Werbebanner enthalten.

Erster Ansatz - funktioniert so leider nicht

Gar nicht schwer, wird quasi hier beschrieben: Ich überschreibe die Funktion getView() von meinem ListAdapter. Jede x. View wird mit einem Werbebanner befüllt.

Eine if-Anweisung und eine Positionsberechnung später:
public class FoodListAdapter extends ArrayAdapter {
  private int adCounter = 7;

  [...]

  public FoodListAdapter(Context context, 
   int textViewResourceId, List foods) {
    [...]
  }

  @Override
  public View getView(final int position, View convertView, 
   ViewGroup parent) {

    // no ad on first position
    if (position % adCounter == 0 && position != 0) {
      // show ad
      if (convertView instanceof AdView) {
        // reuse old AdView
        return convertView;
      } else {
        Activity activity = (Activity) context;
        AdView adView = new AdView(activity);
        adView.setAdSize(AdSize.BANNER);
        adView.setAdUnitId(Utils.AD_UNIT_ID_LIST);

        float density = 
         activity.getResources().getDisplayMetrics().density;
        int height = Math.round(AdSize.BANNER.getHeight() * density);
        AbsListView.LayoutParams params = new AbsListView
         .LayoutParams(AbsListView.LayoutParams.FILL_PARENT, height);
        adView.setLayoutParams(params);

        AdRequest adRequest = new AdRequest.Builder()
            .addTestDevice(AdRequest.DEVICE_ID_EMULATOR)
            .addTestDevice(Utils.TEST_DEVICE_ID1)
            .addTestDevice(Utils.TEST_DEVICE_ID2)
            .addTestDevice(Utils.TEST_DEVICE_ID3)
            .addTestDevice(Utils.TEST_DEVICE_ID4).build();

        adView.loadAd(adRequest);
        return adView;
      }
    } else {
      // create foodmap list view
      int listPosition = position - ((int) (position / adCounter));
      final Food food = getItem(listPosition);

      if (convertView == null || convertView instanceof AdView) {
        convertView = LayoutInflater.from(getContext())
         .inflate(R.layout.food_list_view, parent, false);
      }

      [...]
      return convertView;
    }
  }
}
Eigentlich sieht auch alles gut aus, bis man zum Ende der Liste scrollt: Hier fehlen genau so viele Einträge wie ich Werbebanner eingebaut habe! Außerdem funktioniert das "resuse old view" nicht, aber das interessiert dann ja auch nicht mehr :)

Alt - Note 2
Neu - One Plus One
Kann man die Funktion getView() entsprechend der Anzahl der insgesamt einzufügenden Werbebanner erneut aufrufen? Nein, zumindest habe ich keine Lösung dafür gefunden. Also auf zu

Plan B

Fakt ist: Die Funktion getView() wird so oft aufgerufen, wie sich Datensätze in der dem ArrayAdapter übergebenen Liste befinden. Also muss die Liste (in diesem Fall die List<Food> foods) entsprechend mit Platzhaltern für die Werbung ergänzt werden:
public List findFoods(List foodTypes,
    List foodColors, String searchString) {

  List foods = new ArrayList();
  SQLiteDatabase dataBase = getReadableDatabase();

  int adCounter = 7;
  int listCounter = 0;

  String sql = "SELECT " + FOOD_NAME_GER + ", "
      + FOOD_PORTION_RECOMMENDED + ", " + TYPE_VALUE
      + ", COUNT(CASE WHEN " + RATING_VALUE + " = 1 THEN 1 END)"
      + ", COUNT(CASE WHEN " + RATING_VALUE + " = 0 THEN 1 END), "
      + FOOD_ID;
  sql += " FROM " + TABLE_FOOD;
  sql += " LEFT JOIN " + TABLE_TYPE;
  sql += " ON " + FOOD_TYPE_FK + " = " + TYPE_ID;
  sql += " LEFT JOIN " + TABLE_RATING;
  sql += " ON " + FOOD_ID + " = " + RATING_FOOD_FK;
  [...]

  Cursor res = dataBase.rawQuery(sql, null);

  while (res.moveToNext()) {
    Food food = new Food();
    food.foodName = res.getString(0);
    [...]

    if (foodColors.contains(food.foodColor)) {
      foods.add(food);
      listCounter++;

      // ad
      if (Utils.doAd) {
        // no ad on first position
        if (listCounter % adCounter == 0 && Utils.doAd
            && listCounter != 0) {
          // add ad dummy
          Food adMob = new Food();
          adMob.foodName = "admob";
          foods.add(adMob);
        }
      }
    }
  }

  res.close();
  dataBase.close();

  return foods;
}
Natürlich ist es nicht besonders schön, die Werbung in eine Food-Instanz zu stecken ... aber es funktioniert.

Die Funktion getView() muss nun nur noch abgespeckt und etwas umgeschrieben werden:
private AdView adView;
[...]

@Override
public View getView(final int position, View convertView, 
 ViewGroup parent) {
  final Food food = getItem(position);

  if (food.foodName.equals("admob")) {
    // show ad
    if (adView != null) {
      // reuse old AdView
      return convertView;
    } else {
      // create a new AdView
      Activity activity = (Activity) context;
      adView = new AdView(activity);
      adView.setAdSize(AdSize.BANNER);
      adView.setAdUnitId(Utils.AD_UNIT_ID_LIST);

      float density = activity.getResources().getDisplayMetrics().density;
      int height = Math.round(AdSize.BANNER.getHeight() * density);
      AbsListView.LayoutParams params = new AbsListView
       .LayoutParams(AbsListView.LayoutParams.FILL_PARENT, height);
      adView.setLayoutParams(params);

      AdRequest adRequest = new AdRequest.Builder()
          .addTestDevice(AdRequest.DEVICE_ID_EMULATOR)
          .addTestDevice(Utils.TEST_DEVICE_ID1)
          .addTestDevice(Utils.TEST_DEVICE_ID2)
          .addTestDevice(Utils.TEST_DEVICE_ID3)
          .addTestDevice(Utils.TEST_DEVICE_ID4).build();

      adView.loadAd(adRequest);
      return adView;
    }
  } else {
    // create foodmap list view
    if (convertView == null || convertView instanceof AdView) {
      convertView = LayoutInflater.from(getContext())
       .inflate(R.layout.food_list_view, parent, false);
    }

    [...]
    
    return convertView;
  }
}

Ist das so erlaubt?

Ich habe mir jetzt noch die Finger wund gegoogelt, ob die Positionierung der Werbebanner so erlaubt ist. Immerhin gibt es viele Regeln zu beachten, wenn man einen Blick in die "Content policies" wirft.

Unter "View ad placement policies" findet man den folgenden Text:
Number of ads per page
The number of ads on a single screen should not exceed one if the ad is fixed to the screen top or screen bottom. If the page scrolls, only one ad should be visible on the screen at a time, and, according to the AdSense program policies, publishers may place no more than 3 ad units on one entire page.
Ob das "more then 3 ad units on one entire page" sind, darüber kann man jetzt sicherlich streiten. Aber zu sehen sein sollte (bei Smartphones) immer nur ein Werbebanner. Für Tablets sollte vielleicht eine Anpassung gemacht werden (einfach den adCounter in dem Fall hochsetzen).

Mittwoch, 18. Februar 2015

LibGDX - Teil 3: Google Play Game Services in einem libGDX-Android-Projekt

oder

Wie viele Punkte habt ihr denn so?


Dieser Beitrag zeigt, wie Google Play Game Services in ein libGDX-Projekt eingebaut werden. Als Basis wird das Projekt verwendet, welches im 2. Teil um AdMob ("AdMob in einem libGDX-Android-Projekt") erweitert wurde.

Google Play Game Services

Ich habe eine persönliche Vorstellung davon, was ich erwarte, wenn mich ein Spiel bei Google anmeldet: Beim ersten Start des Spiels möchte ich gefragt werden, ob ich mich anmelden möchte. Wenn ich verneine, soll sich das Spiel das gefälligst merken. Genauso soll es sich merken, wenn ich zwar erst angemeldet war, mich dann aber explizit abgemeldet habe. In dem Fall möchte ich nicht bei jedem Neustart des Spiels wieder mit der Anmeldung belästigt werden. Wenn ich mich anmelden möchte, werde ich das schon tun! Anders ist es, wenn ich beim Beenden des Spiels angemeldet war, dann möglich ich selbstmurmelnd bei jedem Start automatisch angemeldet werden. Als Diagramm sieht das etwa so aus:


Ob das jetzt die beste Lösung ever ist, sei dahin gestellt. Wahrscheinlich hat jeder seine eigenen persönlichen Vorlieben.

Im gleichen Blog, in dem die Einbindung der Werbung in libGDX-Projekte beschrieben ist, findet sich ein Beitrag zur Einbindung der Google Play Game Services: "LibGDX Google Play Game Services Tutorial". Hier wird auf Ein- und Ausloggen verzichtet. Das Spiel geht davon aus, dass sich der Spieler einloggen möchte. Bei jedem Start. Immer. Kein Problem, man kann den Code ja anpassen.

Die basegameutils

Zuerst einmal braucht man ein paar Klassen, die Google netterweise zur Verfügung stellt. Man findet sie bei den Beispielprojekten in den libraries, die basegameutils. Der Einfachheit halber wird der Ordner komplett ins Projekt gepackt:


Falls man in den vier Klassen Änderungen macht, sollte man bedenken, dass man sich diese bei einem Update wieder überbügelt. Bisher bin ich übrigens ohne Änderungen ausgekommen und habe den Code lediglich zum Debuggen verwendet.

Der ActionResolver

Darüber hinaus kommt wieder der ActionResolver zum Einsatz, der bereits bei der Einbindung von AdMob gute Dienste geleistet hat. Die Klasse sieht jetzt so aus, um die oben beschriebene Funktionalität bieten zu können:
public interface ActionResolver {
  [...]

  // gpgs - check if it is an android game - show google buttons
  public boolean isAndroidGame();

  // gpgs - check if signed in
  public boolean isSignedInToGPGS();

  // gpgs - sign in triggered by user
  public void signInToGPGS();

  // gpgs - sign out triggered by user
  public void signOutOfGPGS();

  // gpgs - adjust the score between different devices
  public void loadCurrentPlayerLeaderboardScore();

  // gpgs - adjust the score between different devices
  public int getCurrentPlayerLeaderboardScore();

  // gpgs - unlock achievement - management is in android class
  public void unlockAchievementOnGPGS(int score);

  // gpgs - save new highscore
  public void submitHighscoreToGPGS(int score);

  // gpgs - calls the achievements intent
  public void getGPGSAchievements();

  // gpgs - calls the leaderboad intent
  public void getGPGSLeaderboard();
}

Anzeige der Google-Buttons

Die Funktionen isAndroidGame() und isSignedInToGPGS() werden benötigt, um die Anzeige der Google-Buttons zu steuern. Wenn das Spiel auf einem Android-Gerät gespielt wird, wird mindestens der Google-Sign-In-Button angezeigt. Dazu aus der GameRenderer-Klasse:
private void drawGoogleButtons() {
  if (actionResolver.isAndroidGame()) {
    SimpleButton googleButton = ((InputHandler) Gdx.input
        .getInputProcessor()).getGoogleButton();

    if (actionResolver.isSignedInToGPGS()) {
      // signed in
      if (googleButton.getButtonDown().equals(
          AssetLoader.googleRedButtonDown)) {
        // change to white
        googleButton.setTexture(AssetLoader.googleWhiteButtonUp,
            AssetLoader.googleWhiteButtonDown);
      }

      ((InputHandler) Gdx.input.getInputProcessor())
          .getGoogleAchievements().draw(batcher);
      ((InputHandler) Gdx.input.getInputProcessor())
          .getGoogleLeaderboard().draw(batcher);
    } else {
      // not signed in
      if (googleButton.getButtonDown().equals(
          AssetLoader.googleWhiteButtonDown)) {
        // change to red
        googleButton.setTexture(AssetLoader.googleRedButtonUp,
            AssetLoader.googleRedButtonDown);
      }
    }
    googleButton.draw(batcher);
  }
}
Wie zu erkennen ist, gibt es in der InputHandler-Klasse drei Buttons: Achievements, Leaderboard(s) und SignIn/SignOut:
googleAchievements = new SimpleButton(5 * 3 + 4 * 16, 3, 16, 16,
    AssetLoader.googleAchievementsUp,
    AssetLoader.googleAchievementsDown);

googleLeaderboard = new SimpleButton(6 * 3 + 5 * 16, 3, 16, 16,
    AssetLoader.googleLeaderboardUp,
    AssetLoader.googleLeaderboardDown);

// google red or white 32x32
if (myWorld.isSignedInToGPGS()) {
  googleButton = new SimpleButton(myWorld.getGameWidth() - (16 + 3),
      3, 16, 16, AssetLoader.googleWhiteButtonUp,
      AssetLoader.googleWhiteButtonDown);
} else {
  googleButton = new SimpleButton(myWorld.getGameWidth() - (16 + 3),
      3, 16, 16, AssetLoader.googleRedButtonUp,
      AssetLoader.googleRedButtonDown);
}
Auf alle drei Buttons wird im GameRenderer über neue Get-Methoden im InputHandler zugegriffen:
public SimpleButton getGoogleAchievements() {
  return googleAchievements;
}

public SimpleButton getGoogleLeaderboard() {
  return googleLeaderboard;
} 

public SimpleButton getGoogleButton() {
  return googleButton;
}
Beim letzten Button wechselt die Texture zwischen rot und weiß - je nachdem ob der User sich noch einloggen muss oder schon eingeloggt ist.
Not signed in
Signed in
Die Gestaltung dieser Buttons ist natürlich jedem selber überlassen, sofern sie den Google Branding-Richtlinien entspricht.

Die GameWorld-Klasse musste dazu um einen Wrapper erweitert werden, da in der InputHandler-Klasse der ActionResolver nicht zur Hand ist:
public boolean isSignedInToGPGS() {
  return actionResolver.isSignedInToGPGS();
}
Gleiches gilt für die signInToGPGS()- und die signOutOfGPGS()-Funktion, die ebenfalls im InputHandler verwendet werden und die Berührungen der Google-Buttons verarbeiten:
if (googleButton.isTouchUp(screenX, screenY, false)) {
  if (googleButton.getButtonDown().equals(
      AssetLoader.googleRedButtonDown)) {
    // sign in
    myWorld.signInToGPGS();
  } else {
    // sign out
    googleButton.setTexture(AssetLoader.googleRedButtonUp,
        AssetLoader.googleRedButtonDown);
    myWorld.signOutOfGPGS();
  }
}
if (googleAchievements.isTouchUp(screenX, screenY, false)) {
  myWorld.getAchievementsGPGS();
}
if (googleLeaderboard.isTouchUp(screenX, screenY, false)) {
  myWorld.getLeaderboardGPGS();
}
In den Zeilen 13 bis 18 sieht man auch gleich die Verarbeitung des Aufrufs von Achievement- und Leaderboard(s)-Seite.

Abruf von Daten der Google Play Game Services

Beim Testen des Spiels ist aufgefallen, dass wir - bedingt durch mehrere Geräte in unserem Haushalt - trotz gleichem Google-Login unterschiedliche "Top"-Werte in der Highscore stehen hatten. Klar, logo, kommt der Wert doch aus den Gerät-internen Preferences.


Abhilfe schaffen die Funktionen loadCurrentPlayerLeaderboardScore() und getCurrentPlayerLeaderboardScore(). Der Aufruf der ersten Funktion, die den aktuell vom Google-Account gespeicherten Highscore-Wert holt, arbeitet mit einem Callback (siehe unten im Code der AndroidLauncher-Klasse, Zeile 315ff). Daher wird das Laden des Wertes direkt zu Beginn der updateRunning(float delta)-Funktion angestoßen:
// start calling gpgs highscore
actionResolver.loadCurrentPlayerLeaderboardScore();
Am Ende des Spiels (= wenn der Toaster mit der Grundlinie zusammengestoßen ist), wird der Wert, der hoffentlich zwischenzeitlich angekommen ist, abgerufen und gegebenenfalls gespeichert:
// save gpgs highscore if higher than local highscore
int googleHighscore = actionResolver
    .getCurrentPlayerLeaderboardScore();
if (googleHighscore > AssetLoader.getHighScore()) {
  AssetLoader.setHighScore(googleHighscore);
}
Da die drawScoreboard()-Funktion den Wert immer frisch vom AssetLoader abruft, wird nun der korrigierte Wert angezeigt. Dies funktioniert übrigens auch nach der De- und Neuinstallation des Spiels.

Freigabe von Achievements, Speichern der Punktzahl

Ebenfalls nach jedem Spielende werden die erreichten Punkte durchgereicht, im Leaderboard gespeichert und gegebenenfalls Achievements freigeschaltet:
// google achievement
actionResolver.unlockAchievementOnGPGS(score);
// google leaderboard
actionResolver.submitHighscoreToGPGS(score);

Die AndroidLauncher-Klasse

Die geheimnisvolle Implementierung der Android-Klasse sieht nun so aus:
public class AndroidLauncher extends AndroidApplication implements
    GameHelperListener, ActionResolver {

  // AdMob
  private static final String AD_UNIT_ID_BANNER
    = "YOUR_BANNER_ID";
  private static final String AD_UNIT_ID_INTERSTITIAL 
    = "YOUR_INTERSTITIAL_ID";
  private static final String TEST_DEVICE_ID 
    = "YOUR_DEVICE_ID";

  // important for handling sign out from achievement from leaderboard
  private static final int REQUEST_ACHIEVEMENTS = 1212;
  private static final int REQUEST_LEADERBOARD = 3434;

  // AdMob
  protected AdView adView;
  protected View gameView;
  private InterstitialAd interstitialAd;

  // GooglePlayGameServices
  private GameHelper gameHelper;
  private int highscore = -1;
  private boolean highscoreIsLoading = false;
  private SharedPreferences prefs; // saves variable for automatic login

  // TODO set to false before publish the game
  private boolean writeLogs = true;

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    AndroidApplicationConfiguration config 
      = new AndroidApplicationConfiguration();

    // important for images on android device
    config.useGL20 = true;
    config.useAccelerometer = false;
    config.useCompass = false;

    requestWindowFeature(Window.FEATURE_NO_TITLE);
    getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN,
        WindowManager.LayoutParams.FLAG_FULLSCREEN);
    getWindow().clearFlags(
        WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN);

    RelativeLayout layout = new RelativeLayout(this);
    RelativeLayout.LayoutParams params 
      = new RelativeLayout.LayoutParams(
        RelativeLayout.LayoutParams.MATCH_PARENT,
        RelativeLayout.LayoutParams.MATCH_PARENT);
    layout.setLayoutParams(params);

    AdView adView = createAdView();
    layout.addView(adView);

    View gameView = createGameView(config);
    layout.addView(gameView);

    setContentView(layout);
    startAdvertising(adView);

    interstitialAd = new InterstitialAd(this);
    interstitialAd.setAdUnitId(AD_UNIT_ID_INTERSTITIAL);

    if (gameHelper == null) {
      gameHelper = new GameHelper(this, GameHelper.CLIENT_GAMES);
      // logs may be helpful
      gameHelper.enableDebugLog(writeLogs);
    }
    gameHelper.setup(this);
  }
  [... createAdView() ... createGameView() ... startAdvertising ...]

  @Override
  protected void onStart() {
    super.onStart();

    prefs = PreferenceManager.getDefaultSharedPreferences(this);

    if (writeLogs) {
      Gdx.app.log(
          "FlyingToaster.Android onStart",
          "prefs contains automatic_login = "
              + prefs.contains("google_automaticlogin"));
      Gdx.app.log(
          "FlyingToaster.Android onStart",
          "prefs automatic_login = "
              + prefs.getBoolean("google_automaticlogin", false));
    }

    // if google_automaticlogin not found: start automatic sign in
    if (prefs.getBoolean("google_automaticlogin", true)) {
      if (writeLogs)
        Gdx.app.log("FlyingToaster.Android onStart",
            "automatic login ...");

      gameHelper.setConnectOnStart(true); // default
    } else {
      if (writeLogs)
        Gdx.app.log("FlyingToaster.Android onStart",
            "NO automatic login ...");

      // important - otherwise always sign in on start
      gameHelper.setConnectOnStart(false);
    }
    gameHelper.onStart(this);
  }

  @Override
  protected void onStop() {
    super.onStop();
    gameHelper.onStop();
  }

  /**
   * Checks {@code requestCode} and {@code resultCode}.
   * 
   * @param requestCode
   *     9001 - used from {@code GameHelper}: Request code we use
   *       when invoking other Activities to complete the sign-in flow.
   *     {@value #REQUEST_ACHIEVEMENTS} - used for achievements
   *       intent
   *     {@value #REQUEST_LEADERBOARD} 
   *       - used for leaderboard intent
   * @param resultCode
   *    user signed in (-1)
   *    RESULT_RECONNECT_REQUIRED (10001)
   *    RESULT_SIGN_IN_FAILED (10002)
   *    RESULT_APP_MISCONFIGURED (10004)
   *    RESULT_NETWORK_FAILURE (10006)
   *
   * @param data
   */
  @Override
  protected void onActivityResult(int requestCode, int resultCode, 
   Intent data) {
    super.onActivityResult(requestCode, resultCode, data);

    if (writeLogs) {
      Gdx.app.log("FlyingToaster.Android onActivityResult1a",
          "requestCode = " + requestCode + ", resultCode = "
              + resultCode);

      if (requestCode == 9001 && resultCode == -1)
        Gdx.app.log("FlyingToaster.Android onActivityResult1b",
            "user signed in!");

      if (requestCode == 9001
          && resultCode == GamesActivityResultCodes.RESULT_SIGN_IN_FAILED)
        Gdx.app.log("FlyingToaster.Android onActivityResult1c",
            "sign in failed, e.g. airplane mode");

      if (requestCode == REQUEST_ACHIEVEMENTS && resultCode == 0)
        Gdx.app.log("FlyingToaster.Android onActivityResult1d",
            "getGPGSAchievements closed.");

      if (requestCode == REQUEST_LEADERBOARD && resultCode == 0)
        Gdx.app.log("FlyingToaster.Android onActivityResult1e",
            "getGPGSLeaderboard closed.");
    }

    if ((requestCode == 9001 && resultCode == 0)
     || (((requestCode == REQUEST_ACHIEVEMENTS 
     || requestCode == REQUEST_LEADERBOARD) 
     && resultCode == GamesActivityResultCodes.RESULT_RECONNECT_REQUIRED)
     || (resultCode == GamesActivityResultCodes.RESULT_SIGN_IN_FAILED)
     || (resultCode == GamesActivityResultCodes.RESULT_APP_MISCONFIGURED) 
     || (resultCode == GamesActivityResultCodes.RESULT_NETWORK_FAILURE))) 
     {

      if (writeLogs)
        Gdx.app.log(
            "FlyingToaster.Android onActivityResult2a",
            "requestCode = " + requestCode + ", resultCode = "
                + resultCode + ", signedIn = "
                + gameHelper.isSignedIn());

      if (requestCode == 9001 && resultCode == 0) {
        if (writeLogs)
          Gdx.app.log("FlyingToaster.Android onActivityResult2b",
              "user CANCELLED sign in!");

        setAutomaticLogin(false);
      } else {
        if (writeLogs)
          Gdx.app.log(
              "FlyingToaster.Android onActivityResult2b",
              "user signed out from achievements/leaderboard "
                  + "or connection problem, apiClient is connected = "
                  + gameHelper.getApiClient().isConnected());

        // diconnect important
        gameHelper.disconnect();
      }
    } else {
      gameHelper.onActivityResult(requestCode, resultCode, data);
    }
  }

  @Override
  public void onResume() {
    super.onResume();

    if (adView != null)
      adView.resume();
  }

  @Override
  public void onPause() {
    if (adView != null)
      adView.pause();
    super.onPause();
  }

  @Override
  public void onDestroy() {
    if (adView != null)
      adView.destroy();
    super.onDestroy();
  }

  @Override
  public boolean isAndroidGame() {
    return true;
  }

  @Override
  public boolean isSignedInToGPGS() {
    // Gdx.app.log("FlyingToaster.Android",
    // "isSignedIn = " + gameHelper.isSignedIn());
    return gameHelper.isSignedIn();
  }

  @Override
  public void signInToGPGS() {
    if (writeLogs)
      Gdx.app.log("FlyingToaster.Android signInToGPGS", "loginGPGS ...");

    try {
      runOnUiThread(new Runnable() {
        public void run() {
          gameHelper.beginUserInitiatedSignIn();
        }
      });
    } catch (final Exception ex) {

    }
  }

  @Override
  public void signOutOfGPGS() {
    if (writeLogs)
      Gdx.app.log("FlyingToaster.Android signOutOfGPGS", "...");
    gameHelper.signOut();
    setAutomaticLogin(false);
  }
  
  @Override
  public void unlockAchievementOnGPGS(int score) {
    if (writeLogs)
      Gdx.app.log("FlyingToaster.Android unlockAchievementOnGPGS",
          "score = " + score + ", signedIn = " + isSignedInToGPGS());

    if (gameHelper.isSignedIn()) {
      if (score >= 3) {
        Games.Achievements.unlock(gameHelper.getApiClient(),
            getString(R.string.achievement_3_forks));
      }
      if (score >= 11) {
        Games.Achievements.unlock(gameHelper.getApiClient(),
            getString(R.string.achievement_11_forks));
      }
      if (score >= 35) {
        Games.Achievements.unlock(gameHelper.getApiClient(),
            getString(R.string.achievement_35_forks));
      }
      if (score >= 75) {
        Games.Achievements.unlock(gameHelper.getApiClient(),
            getString(R.string.achievement_75_forks));
      }
      if (score >= 131) {
        Games.Achievements.unlock(gameHelper.getApiClient(),
            getString(R.string.achievement_131_forks));
      }
    }
  }

  @Override
  public void getGPGSAchievements() {
    if (writeLogs)
      Gdx.app.log("FlyingToaster.Android getGPGSAchievements",
          "isSignedIn = " + isSignedInToGPGS());

    if (gameHelper.isSignedIn()) {
      startActivityForResult(
          Games.Achievements.getAchievementsIntent(gameHelper
              .getApiClient()), REQUEST_ACHIEVEMENTS);
    } else if (!gameHelper.isConnecting()) {
      signInToGPGS();
    }
  }

  @Override
  public void loadCurrentPlayerLeaderboardScore() {
    if (gameHelper.isSignedIn() && !highscoreIsLoading) {
      highscoreIsLoading = true;

      Games.Leaderboards
          .loadCurrentPlayerLeaderboardScore(
              gameHelper.getApiClient(),
              getString(R.string.leaderboard_flying_toaster__leaderboard),
              LeaderboardVariant.TIME_SPAN_ALL_TIME,
              LeaderboardVariant.COLLECTION_SOCIAL)
          .setResultCallback(
              new ResultCallback() {

                @Override
                public void onResult(
                    LoadPlayerScoreResult result) {
                  if (result != null) {
                    if (GamesStatusCodes.STATUS_OK == result
                        .getStatus().getStatusCode()) {
                      long score = 0;
                      if (result.getScore() != null) {
                        score = result.getScore()
                            .getRawScore();
                      }
                      highscore = (int) score;
                    }
                  }
                }

              });
    }
  }

  @Override
  public int getCurrentPlayerLeaderboardScore() {
    return highscore;
  }

  @Override
  public void submitHighscoreToGPGS(int score) {
    if (writeLogs)
      Gdx.app.log("FlyingToaster.Android submitHighscoreToGPGS",
          "score = " + score + ", signedIn = " + isSignedInToGPGS());

    if (gameHelper.isSignedIn()) {
      Games.Leaderboards
          .submitScore(
              gameHelper.getApiClient(),
              getString(R.string.leaderboard_flying_toaster__leaderboard),
              score);
    }
  }

  @Override
  public void getGPGSLeaderboard() {
    if (writeLogs)
      Gdx.app.log("FlyingToaster.Android getGPGSLeaderboard",
          "isSignedIn = " + isSignedInToGPGS());

    if (gameHelper.isSignedIn()) {
      startActivityForResult(
          Games.Leaderboards
              .getLeaderboardIntent(
                  gameHelper.getApiClient(),
                  getString(R.string.leaderboard_flying_toaster__leaderboard)),
          REQUEST_LEADERBOARD);
    } else if (!gameHelper.isConnecting()) {
      signInToGPGS();
    }
  }
  
  private void setAutomaticLogin(boolean value) {
    if (writeLogs) {
      Gdx.app.log("FlyingToaster.Android setAutomaticLogin",
          "setAutomaticLogin = " + value);
      Gdx.app.log("FlyingToaster.Android setAutomaticLogin",
          "signedIn = " + gameHelper.isSignedIn());
    }

    SharedPreferences.Editor localEditor;
    localEditor = prefs.edit();
    localEditor.putBoolean("google_automaticlogin", value);
    localEditor.commit();
  }

  @Override
  public void onSignInFailed() {
    if (writeLogs)
      Gdx.app.log("FlyingToaster.Android onSignInFailed", "...");
  }

  /**
   * Function from {@code GameHelperListener}.
   */
  @Override
  public void onSignInSucceeded() {
    if (writeLogs)
      Gdx.app.log("FlyingToaster.Android onSignInSucceeded", "...");
    setAutomaticLogin(true);
  }
}

Der aktuelle Status, ob automatisch eingeloggt werden soll, wird in den SharedPreferences gespeichert. Ob die Verarbeitung in onActivityResult() optimal gelöst ist, möchte ich nicht beschwören. Verbesserungsvorschläge nehme ich gerne entgegen!

Ansonsten ist der Code nah an dem Beispiel aus dem Blog.

Tipps

Google Play Game Service Zugriff testen

Wer prüfen möchte, ob die ganze Geschichte genau so agiert, wie oben im Diagramm dargestellt ist, kann sich die Logausgaben ansehen. Über das Google Dashboard kann man dazu unter "Konto" - "Verbundene Apps und Websites" der App den erlaubten Zugriff wieder entziehen:


Falls noch etwas fehlt, gerne melden! Aber: Den kompletten Code werde ich nicht zur Verfügung stellen :)

Samstag, 14. Februar 2015

LibGDX - Teil 2: AdMob in einem libGDX-Android-Projekt

oder

Programmieren macht nicht reich


Dieser Beitrag zeigt, wie man ein libGDX-Projekt mit Werbung (AdMob) erweitert. Als Basis dient das Projekt, was mithilfe des LibGDX Zombie Bird Tutorials erstellt wurde und was ich in Teil 1 (LibGDX Tutorial: In 12 Tagen zum eigenen "Flappy Bird") beschrieben habe.

Werbung im libGDX-Android-Projekt

Im libGDX-Android-Projekt gibt es zu Beginn nur eine einzige Klasse, die die Klasse AndroidApplication erweitert. Der Code ist sehr übersichtlich:
public class MainActivity extends AndroidApplication {
  @Override
  public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    
    AndroidApplicationConfiguration cfg 
        = new AndroidApplicationConfiguration();
    
    initialize((new FlyingToasterGame(), cfg);
  }
}
Wie man Werbung - AdMob - in ein libGDX-Projekt bekommt, wird im Blogbeitrag "LibGdx Google Mobile Ads SDK Tutorial" beschrieben. Ohne die google-play-services_lib geht gar nichts. Diese Library muss erstmal in Eclipse als Projekt importiert und dann im Android-Projekt eingebunden werden:


Wer davon noch nie gehört hat: "Eclipse - Window - Android SDK Manager". "Extras - Google Play services" installieren.


Dann "\sdk\extras\google\google_play_services\libproject\google-play-services_lib" importieren.


Der initialize-Aufruf in der MainActivity muss überschrieben werden, damit man den Aufbau der Oberfläche beeinflussen und das Werbebanner einbauen kann. Den onCreate()-Code kann man fast komplett vom Blog-Beispiel übernehmen:
@Override
protected void onCreate(Bundle savedInstanceState) {
  super.onCreate(savedInstanceState);
  AndroidApplicationConfiguration config 
    = new AndroidApplicationConfiguration();

  config.useGL20 = true;
  config.useAccelerometer = false;
  config.useCompass = false;

  requestWindowFeature(Window.FEATURE_NO_TITLE);
  getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN,
      WindowManager.LayoutParams.FLAG_FULLSCREEN);
  getWindow().clearFlags(
      WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN);

  RelativeLayout layout = new RelativeLayout(this);
  RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(
      RelativeLayout.LayoutParams.MATCH_PARENT,
      RelativeLayout.LayoutParams.MATCH_PARENT);
  layout.setLayoutParams(params);

  AdView adView = createAdView();
  layout.addView(adView);

  View gameView = createGameView(config);
  layout.addView(gameView);

  setContentView(layout);
  startAdvertising(adView);

  interstitialAd = new InterstitialAd(this);
  interstitialAd.setAdUnitId(AD_UNIT_ID_INTERSTITIAL);
}
Wichtig ist nur config.useGL20 = true; - siehe unten. Unter Umständen gibt es sonst keine Bilder.

Das AdMob-Beispiel ist im Querformat, die Werbung ist oben. Ich habe die Positionierung in createAdView() und createGameView() so angepasst, dass die Werbung unten ist. Ich zumindest tippe dort mit dem Finger beim Spielen hin - also die optimale Position.
private AdView createAdView() {
  adView = new AdView(this);
  adView.setAdSize(AdSize.SMART_BANNER);
  adView.setAdUnitId(AD_UNIT_ID_BANNER);

  adView.setId(12345);

  RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(
      LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
  params.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM, RelativeLayout.TRUE);
  params.addRule(RelativeLayout.CENTER_HORIZONTAL, RelativeLayout.TRUE);

  adView.setLayoutParams(params);
  adView.setBackgroundColor(Color.BLACK);

  return adView;
}

private View createGameView(AndroidApplicationConfiguration config) {
  gameView = initializeForView(new FlyingToasterGame(this), config);

  RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(
      LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
  params.addRule(RelativeLayout.ALIGN_PARENT_TOP, RelativeLayout.TRUE);
  params.addRule(RelativeLayout.CENTER_HORIZONTAL, RelativeLayout.TRUE);
  params.addRule(RelativeLayout.ABOVE, adView.getId());

  gameView.setLayoutParams(params);

  return gameView;
}
Bei der startAdvertision()-Funktion bin ich mir nicht sicher, ob sie wie im Beispiel verwendet werden sollte.  Wenn man den Code aus dem Beispiel so ausführt, kann man im Log lesen, dass man ein TestDevice zufügen soll. Ich habe schon öfter gelesen, dass loadAd() sonst nicht funktioniert. Erweiterter Code aus dem Blog mit Logausgabe:
private void startAdvertising(AdView adView) {
  Gdx.app.log("AndroidLauncher", "startAdvertising ...");
  AdRequest adRequest = new AdRequest.Builder().build();
  Gdx.app.log("AndroidLauncher", "AdRequest built.");

  Gdx.app.log("AndroidLauncher", "loadAd ...");
  adView.loadAd(adRequest);
}
Im Log steht dann:
02-14 16:50:51.570: I/AndroidLauncher(4019): startAdvertising ...
02-14 16:50:51.570: I/AndroidLauncher(4019): AdRequest built.
02-14 16:50:51.570: I/AndroidLauncher(4019): loadAd ...
[...]
02-14 16:50:51.730: I/Ads(4019): Starting ad request.
02-14 16:50:51.730: I/Ads(4019): Use AdRequest.Builder.addTestDevice("........") to get test ads on this device.

In meiner Konstanten TEST_DEVICE_ID steht nun der Wert aus addTestDevice("..."), die ich wie folgt verwende:
private void startAdvertising(AdView adView) {
  AdRequest adRequest = new AdRequest.Builder()
      .addTestDevice(AdRequest.DEVICE_ID_EMULATOR)
      .addTestDevice(TEST_DEVICE_ID).build();

  adView.loadAd(adRequest);
}
Die adView muss nun noch wie im Beispiel in onResume(), onPause() und onDestroy() entsprechend behandelt werden, fertig ist das Werbebanner im Spiel.


Um im Spiel - z.B. bei myWorld.isGameOver() - noch ganzseitige Werbung einzubinden, ist ein ActionResolver nötig, der quasi zwischen der Android-Welt und der eigentlichen Spielelogik vermittelt.

Die einzige Funktion in dieser Vermittlungs-Klasse habe ich um einen Parameter erweitert und im helpers-Unterordner angesiedelt.
public interface ActionResolver {
  // android ad - full screen
  void showOrLoadInterstital(boolean showAd);
}
Die Android-Klasse muss nun diesen ActionResolver implementieren, zusätzlich braucht man die neue Variable private InterstitialAd interstitialAd;.

Der Parameter showAd der Funktion bewirkt, dass die Werbung schon mal (vor-)geladen werden kann, damit sie beim Laden bereits parat steht und nur noch angezeigt werden muss:
@Override
public void showOrLoadInterstital(final boolean showAd) {
  try {
    runOnUiThread(new Runnable() {
      public void run() {
        if (interstitialAd.isLoaded() && showAd) {
          interstitialAd.show();
        } else {
          AdRequest interstitialRequest = new AdRequest.Builder()
              .build();
          interstitialAd.loadAd(interstitialRequest);
        }
      }
    });
  } catch (Exception e) {
    // TODO
  }
}
Um die Seite zu laden bzw. anzuzeigen wird aus
if (myWorld.isGameOver() || myWorld.isHighScore()) {
  myWorld.restart();
}
folgender Code:
if (myWorld.isGameOver() || myWorld.isHighScore()) {
  // reset all variables, go to GameState.READY
  myWorld.restart();

  if (myWorld.getTimesToAd() <= 0) {
    myWorld.resetTimesToAd();
    myWorld.getActionResolver().showOrLoadInterstital(true);
  } else {
    myWorld.getActionResolver()
        .showOrLoadInterstital(false);
    myWorld.decrementTimesToAd(1);
  }
}
Da ich nicht nach jedem Crash die Werbeseite einblenden wollte, habe ich einen Zufallsgenerator eingebaut: Nur bei jedem x. isGameOver() bzw. isHighScore() kommt eine ganzseitige Werbung. Im gegenteiligen Fall wird die Werbung lediglich geladen.

Die GameWorld-Klasse muss dazu noch etwas erweitert werden:
public class GameWorld {
  [...]
  private Random rnd = new Random();
  private int timesToAd = 0;

  [...]

  public int getTimesToAd() {
    return timesToAd;
  }

  public void resetTimesToAd() {
    this.timesToAd = rnd.nextInt(5) + 2;
  }

  public void decrementTimesToAd(int decrement) {
    this.timesToAd = timesToAd - decrement;
  }
  [...]
}


Tipps

Weiße Felder statt Bilder auf dem Smartphone

Ich weiß nicht, ob es damit zusammenhängt, dass ich in Teil 1 diese Power-of-2-Geschichte ausgehebelt habe, aber auf drei unserer Smartphones sah man nach der Installation des Spiels nur die "gemalten" Hintergründe, z.B.:
// Draw Background color - sky blue
shapeRenderer.setColor(0 / 255.0f, 0 / 255.0f, 94 / 255.0f, 1);
shapeRenderer.rect(0, 0, gameWidth, groundPointY);

Screenshot vom Nexus One
Da durchweg die "älteren" Modelle mit niedriger(er) Auflösung betroffen waren (Nexus One, LG Optimus 3D ...), möchte ich vermuten, dass es damit zusammenhängt. Die Lösung bringt folgende Zeile:
config.useGL20 = true;
In der MainActivity des Android-Projekts wurde useGL20 im Tutorial ursprünglich auf false gesetzt.

Weiter mit

Google Play Game Services

Donnerstag, 12. Februar 2015

LibGDX - Teil 1: In 12 Tagen zum eigenen "Flappy Bird"

oder

Wie aus "Zombie Bird" der "Flying Toaster" wurde


Dieser Beitrag beschreibt kurz meine Erfahrungen mit dem "LibGDX Zombie Bird Tutorial" und gibt darüber hinaus ein paar Tipps zum Tutorial bzw. der Verwendung von libGDX.

Das Tutorial

Eigentlich hatte ich nie vor, einen "Flappy Bird"-Klon zu programmieren. Ich war lediglich auf der Suche nach einem brauchbaren Tutorial für libGDX, das mir eventuell bei einem anderen Spiel die Kollisionsabfrage erleichtern könnte. Die allwissende Suchmaschine spuckte schließlich das "LibGDX Zombie Bird Tutorial" aus: In 12 Tagen zum eigenen "Flappy Bird"!

Nach acht "Tagen" (man kann durchaus mehrere "Tages-Häppchen" an einem Tag machen) war ich bei meiner Kollisionsabfrage angekommen. Da die Programmierung mit libGDX wirklich Spaß macht, kämpfte ich mich weiter, wobei sich mein Bird erst in ein Beer und schließlich in einen Toaster verwandelte:

"Flappy Bird" ist tot, lang lebe der "Flying Toaster"!
An Tag 10 wird im Tutorial angekündigt, dass in "Unit 2" die Integration von "AdMob" und "Google Play" behandelt werden. Yeah, genau das, was ich noch brauche!, dachte ich. Leider scheint diese "Unit 2" bis heute nicht aufgetaucht zu sein. Zum Glück gibt es trotzdem Hilfe, die ich in separaten Beiträgen vorstellen möchte: "AdMob in einem libGDX-Android-Projekt" und "Google Play Game Services in einem libGDX-Android-Projekt".

Am Ende des Tutorials musste ich feststellen, dass der Code von Tag 11 bereits sehr mager und der von Tag 12 gar nicht mehr dokumentiert ist (Stand 19.01.2015). Aber: Man kann sich alles per Codevergleich zusammensuchen und in den selbst geschriebenen Code einbauen. Irgendwann starteten die Projekte wieder ... und ich hatte vier funktionsfähige Toaster-Projekte auf einmal: Ein fliegender Toaster für Android-Smartphones, einen für den Desktop, einen fürs Web (HTML) und einen - für mich uninteressant - für iOS!

Fazit

Super Tutorial mit einigen wenigen Schwächen, meine Erwartungen wurden mehr als erfüllt! Nachdem ich noch eine Toast-Erweiterung eingebaut, neue Bilder entworfen, das Spielprinzip etwas geändert und die Sounds angepasst hatte, war der "Flying Toaster" geboren.


Tipps zum Tutorial

Import der "Tween Engine Library"

"libGDX Project Generator" vs. "libGDX Project Setup"

An Tag 2 des Tutorials wird das libGDX-Projekt erzeugt. Dabei kommt die Standard "Setup App" zum Einsatz: gdx-setup.jar

libGDX Project Generator

An Tag 11 soll das Projekt um eine (die?) Tween Engine Library ergänzt werden. Im Tutorial dazu:
2. Open up the gdx-setup-ui jar, like we did in Day 2. If you need to download that again, click here.
Hierbei handelt es sich allerdings um ein anderes Tool als an Tag 2: gdx-setup-ui.jar

libGDX Project Setup: Create or Update?
Create a new project
Update the library of an existing project
In meinem Fall war das mit gdx-setup.jar erstellte Projekt aus mir nicht bekannten Gründen nicht komplett deckungsgleich mit der von gdx-setup-ui.jar erwarteten Projektstruktur. So konnte ich zwar mehr oder weniger Auswahlen treffen und ausführen, hatte schließlich aber nur in einigen Projektteilen die tween-enginge-Jars eingebunden. Dummerweise dachte ich, dass zusätzlich ein libGDX-Jar-Update dem Projekt gut tun könnte, sodass schließlich alle Build Paths durcheinander und mit unterschiedlichen Jars gefüttert waren - nichts ging mehr! Die Aufräumarbeiten haben mich einiges an Zeit gekostet, dafür lief danach allerdings auch das HTML-Projekt, was sich beim Zombie-Bird-Beispiel hartnäckig gesträubt hat.

Beim nächsten Mal werde ich gleich gdx-setup-ui.jar zur Erzeugung des Projekts verwenden! Und dann wahrscheinlich auch gleich die Universal Tween Engine mit einbinden. Das ist so ein feines Spielzeug, dass ich ihm einen eigenen Beitrag widmen werde! ("Die Sache mit den Tweens im libGDX-Projekt" - coming soon)

Code aufräumen

Nach dem Tutorial befindet sich an zwei Stellen folgender Code (siehe Tag 7ff):
// Temporary code! Sorry about the mess :)
// We will fix this when we finish the Pipe class.
Die Pipe-Klasse ist eigentlich fertig, der temporäre Code ist geblieben. An diesen und auch an anderen Stellen bietet es sich an, mit Schleifen und Konstanten zu arbeiten. Das macht sich vor allem bezahlt, wenn man die Grafiken austauschen und/oder die Anzahl Hindernisse ändern bzw. weitere Elemente in das Spiel einfügen möchte:
[...]
// originally 'VERTICAL_GAP = 45' in Pipe class
public static final int VERTICAL_GAP = ScrollHandler.VERTICAL_GAP;
// originally 'SKULL_WIDTH = 24' in Pipe class
public static final int FORK_WIDTH = ScrollHandler.FORK_WIDTH;
// originally 'SKULL_HEIGHT = 11' in Pipe class
public static final int FORK_HEIGHT = ScrollHandler.FORK_HEIGHT;

private Obstacle[] obstacles = new Obstacle[ScrollHandler.OBSTACLE_COUNTER];
[...]
// drawPipes()
private void drawObstacles() {
  for (Obstacle obstacle : obstacles) {
    // top
    batcher.draw(obstacleBar, obstacle.getX(), obstacle.getY(),
        obstacle.getWidth(), obstacle.getHeight() - FORK_HEIGHT);
    // bottom
    batcher.draw(obstacleBar, obstacle.getX(), obstacle.getY()
        + obstacle.getHeight() + VERTICAL_GAP + FORK_HEIGHT,
        obstacle.getWidth(), groundPointY
            - (obstacle.getY() + obstacle.getHeight()
                + VERTICAL_GAP + FORK_HEIGHT));
  }
}
Zum Vergleich aus der ursprünglichen drawPipes()-Funktion:
// three times, one for each pipe:
batcher.draw(bar, pipe1.getX(), pipe1.getY(), pipe1.getWidth(),
    pipe1.getHeight());
batcher.draw(bar, pipe1.getX(), pipe1.getY() + pipe1.getHeight() + 45,
    pipe1.getWidth(), midPointY + 66 - (pipe1.getHeight() + 45));
Jede Pipe wurde ursprünglich einzeln erzeugt (in der ScrollHandler-Klasse), übergeben (in der Funktion initGameObjects() der GameRenderer-Klasse), gezeichnet (ebenfalls in der GameRenderer-Klasse). Gleiches gilt für die Endstücke:
// drawSkulls()
private void drawForks() {
  for (Obstacle obstacle : obstacles) {
    // x = x - (forkWidth - barWidth) / 2; y = height - forkHeight
    batcher.draw(
        forkTop,
        obstacle.getX() - (FORK_WIDTH - obstacle.getWidth()) / 2.0f,
        obstacle.getY() + obstacle.getHeight() - FORK_HEIGHT,
        FORK_WIDTH, FORK_HEIGHT);
    // x as top; y = y (is 0) + height + VERTICAL_GAP
    batcher.draw(
        forkBottom,
        obstacle.getX() - (FORK_WIDTH - obstacle.getWidth()) / 2.0f,
        obstacle.getY() + obstacle.getHeight() + VERTICAL_GAP,
        FORK_WIDTH, FORK_HEIGHT);
  }
}
Aus der ursprünglichen drawSkulls()-Funktion:
// three times, one for each skull:
batcher.draw(skullUp, pipe1.getX() - 1,
    pipe1.getY() + pipe1.getHeight() - 14, 24, 14);
batcher.draw(skullDown, pipe1.getX() - 1,
    pipe1.getY() + pipe1.getHeight() + 45, 24, 14);
Die Konstanten werden alle in der ScrollHandler-Klasse verwaltet, da auch meine Obstacle-Klasse (ehemals Pipe-Klasse) mit ihrer update()-Funktion darauf zugreifen muss. Ursprünglich wurden die Werte nur in der Pipe-Klasse festgelegt, an anderen Stellen (siehe oben) wurde mit berechneten Integer-Werten gearbeitet.

Das obstacles-Array kommt ebenfalls aus der ScrollHandler-Klasse:
for (int i = 0; i < obstacles.length; i++) {
  if (i == 0) {
    // first
    obstacles[i] = new Obstacle(210, 0, 9, 60, SCROLL_SPEED,
        groundPointY, rnd);
  } else {
    // depends on obstacle[i - 1]
    obstacles[i] = new Obstacle(obstacles[i - 1].getTailX()
        + getNextObstacleGap(), 0, 9, 60, SCROLL_SPEED,
        groundPointY, rnd);
  }
}
Das Random-Objekt übergebe ich, damit nicht für jedes Hindernis ein eigenes erzeugt werden muss. Das wäre nicht unbedingt nötig gewesen, da new Random(); im Konstruktor der Pipe-/Obstacle-Klasse steht und somit im Zombie-Fall drei-, im Toaster-Fall fünfmal aufgerufen wird.

goundPointY ist ein weiterer Wert, den ich einmalig in der GameWorld-Klasse festlege, um den Austausch von Bildern einfacher zu gestalten: Hierbei handelt es sich um die Y-Position, an der der Ground beginnt, der also wichtig für die Kollisionsabfrage mit dem fliegenden Objekt ist. Ursprünglich war hier fest midPointY + 66 angegeben. Da ich länger mit Hinter- und Vordergrundbildern gespielt habe, war diese Variable eine große Hilfe.

Die drawScoreboard()-Funktion habe ich ähnlich ausgemistet:
private void drawScoreboard() {
  batcher.draw(scoreboard, myWorld.getGameWidth() / 2 - (100 / 2),
      midPointY - 25, 100, 37);

  // 3 > 11 > 35 > 75 > 131
  for (int i = 0; i < 5; i++) {
    if (myWorld.getScore() >= i * i * 8 + 3) {
      batcher.draw(star, 22 + i * 12, midPointY - 5, 10, 9);
    } else {
      batcher.draw(noStar, 22 + i * 12, midPointY - 5, 10, 9);
    }
  }

  float length = getUsedWidth(myWorld.getScore());
  AssetLoader.whiteFont.draw(batcher, "" + myWorld.getScore(),
      100 - length / 2.0f, midPointY - 16);

  float length2 = getUsedWidth(AssetLoader.getHighScore());
  AssetLoader.whiteFont.draw(batcher, "" + AssetLoader.getHighScore(),
      100 - length2 / 2.0f, midPointY + 2);
}
Die Funktion getUsedWidth musste ich übrigens schreiben, da meine Schriftart nicht für alle Ziffern die gleiche Breite beansprucht. Wenn ich - wie im Tutorial - für jede Ziffer eine feste Breite angenommen hätte, wären die Zahlen in den seltensten Fällen in der Mitte gewesen.

Die Optimierung des Codes hilft auf jeden Fall, das Zusammenspiel der einzelnen Komponenten noch besser zu verstehen. Und spätestens wenn die Kollisionsabfrage korrekt funktionieren soll, sieht man, ob man sauber gearbeitet hat:




Power of 2 - Bilder in allen Formaten

Im Tutorial wird es nicht erwähnt, aber eigentlich erlaubt libGDX nur 2er Pozenzen als Kantenlänge der eingelesenen Images bzw. Texturen. Bei Missachtung kommt es zu folgendem Fehler:

Exception in thread "LWJGL Application" com.badlogic.gdx.utils.GdxRuntimeException: Texture width and height must be powers of two: 512x41
 at com.badlogic.gdx.graphics.GLTexture.uploadImageData(GLTexture.java:241)
 at com.badlogic.gdx.graphics.Texture.load(Texture.java:145)
 at com.badlogic.gdx.graphics.Texture.<init>(Texture.java:133)
 at com.badlogic.gdx.graphics.Texture.<init>(Texture.java:112)
 at com.badlogic.gdx.graphics.Texture.<init>(Texture.java:104)
 at de.morgaine1976.flyingtoaster.helpers.AssetLoader.load(AssetLoader.java:57)
 at de.morgaine1976.flyingtoaster.FlyingToasterGame.create(FlyingToasterGame.java:31)
 at com.badlogic.gdx.backends.lwjgl.LwjglApplication.mainLoop(LwjglApplication.java:136)
 at com.badlogic.gdx.backends.lwjgl.LwjglApplication$1.run(LwjglApplication.java:114)

Wer die Größe seiner verwendeten Images frei bestimmen möchte, fügt zu Beginn der load()-Funktion in der AssetLoader-Klasse folgende Zeile ein:
GLTexture.setEnforcePotImages(false);

Nicht lebensnotwendig aber hilfreich.

Übrigens

Wer nach dem Durcharbeiten des Codes ebenfalls ein Spiel im Google Play Store veröffentlichen möchte: "Flappy"-Spiele werden nicht mehr akzeptiert. Der fliegende Toaster hat es jedoch offensichtlich geschafft:


Weiter mit

AdMob oder
Google Play Game Services