Sunday, February 1, 2015

Anansi: Statistics Overlay


Now that we can hit enemies and collect items, we very much would like to see these data on the screen.

Our statistics overlay could include several items per player:

  • Score
  • Health bar
  • Number of collected items
  • Number of ships left
  • Number of retries (usually coins) left after a game over
  • Number of special bombs left
  • Rank
  • ...

For now we settle with a score, a health bar and the number of collected items. Adding the other data is easy once the framework is set up.

We create a dedicated statistics overlay component. For now we have a single player game, but we should think ahead and plan on having a multiplayer game. So we create statistics for every player. The main question is: Who keeps track of the statistics and the update? The player or a dedicated Statistics component? We'll settle for a dedicated component.

What we need to do is to add another layer on top of all the other layers.
Pane statisticsLayer;
...
root.getChildren().add( backgroundLayer);
root.getChildren().add( lowerCloudLayer);
root.getChildren().add( bulletLayer);
root.getChildren().add( playfieldLayer);
root.getChildren().add( upperCloudLayer);
root.getChildren().add( statisticsLayer);
root.getChildren().add( debugLayer);
Then we add various statistics attributes to the player class. We add a score holder, a bonus item holder and a health calculation mechanism. We'll use a health bar for the health display, so the fill state of it will be between 0 and 100% or more exactly it'll range from 0.0 to 1.0.
private double healthMax = Settings.PLAYER_SHIP_HEALTH;
private long score = 0;
private int bonusCollected = 0;

/**
 * Health as a value from 0 to 1.
 * @return
 */
public double getRelativeHealth() {
 return getHealth() / healthMax;
}

public void resetScore() {
 this.score = 0;
}

public void addScore( long points) {
 this.score += points;
}

public long getScore() {
 return this.score;
}

public void incBonusCollected() {
 addBonusCollected(1);
}

public void addBonusCollected( int count) {
 this.bonusCollected += count;
}

public int getBonusCollected() {
 return this.bonusCollected;
}

We create a health bar that consists of 2 rectangles: An outer and an inner rectangle. The outer shows the total health, the inner the current health.
public class HealthBar extends Pane {
 
 Rectangle outerHealthRect;
 Rectangle innerHealthRect;
 
 public HealthBar() {
  
  double height = 10;
  
  double outerWidth = 60;
  double innerWidth = 40;
  
  double x=0.0;
  double y=0.0;
  
  outerHealthRect = new Rectangle( x, y, outerWidth, height);
  outerHealthRect.setStroke(Color.BLACK);
  outerHealthRect.setStrokeWidth(2);
  outerHealthRect.setStrokeType( StrokeType.OUTSIDE);
  outerHealthRect.setFill(Color.WHITE);

  innerHealthRect = new Rectangle( x, y, innerWidth, height);
  innerHealthRect.setStrokeType( StrokeType.OUTSIDE);
  innerHealthRect.setFill(Color.LIMEGREEN);

  getChildren().addAll( outerHealthRect, innerHealthRect);

 }
 
 public void setValue( double value) {
  innerHealthRect.setWidth( outerHealthRect.getWidth() * value);
 }
 
}

The tricky part in this is to keep the health bar smooth. JavaFX calculates in doubles and if we'd simply create 2 rectangles, then some antialiasing will be applied and the bar wouldn't be crisp. You'd get some shades of colors, it would look blurry. To overcome this problem there are several solutions. One would be to snap to grid by casting the location value to int and then applying 0.5 to it. Another would be to create an entire pane that does these adjustments for us. And then there's simply to not use strokes of types CENTERED. I really would like to avoid calculations with 0.5 because where do we start with messing around and where does it end? So the solution we use is a different stroke type.

The Statistics component is a simple StackPane in which we layout our score, health bar, an icon with the bonus items and a number next to it showing the number of collected bonus items.
public class Statistics extends StackPane {

 Player player;
 
 HealthBar healthBar;
 Text scoreText;
 Text bonusText;
 
 DecimalFormat bonusFormatter = new DecimalFormat( "x0000");
 
 // TODO: use image manager for bonus image
 public Statistics( Player player, Image bonusImage) { 
  
  this.player = player;
  
  setPadding( new Insets(2));
  
  // health bar
  healthBar = new HealthBar();
  StackPane.setAlignment( healthBar, Pos.TOP_LEFT);
  StackPane.setMargin( healthBar, new Insets( 6,0,0,6));
  
  getChildren().add( healthBar);

  
  // score
  scoreText = new Text();
  scoreText.setFont(Font.font("Arial", FontWeight.BOLD, 26)); // TODO: css
  scoreText.setStroke(Color.BLACK);
  scoreText.setFill(Color.WHITE);
  
  StackPane.setAlignment( scoreText, Pos.TOP_CENTER);
  
  getChildren().add( scoreText);

  // bonus collection icon
  ImageView bonusImageView = new ImageView( bonusImage);
  
  StackPane.setAlignment( bonusImageView, Pos.TOP_LEFT);
  StackPane.setMargin( bonusImageView, new Insets( 20,0,0,6));
  
  getChildren().add( bonusImageView);

  // bonus collection score
  bonusText = new Text("x0000");
  bonusText.setFont(Font.font("Arial", FontWeight.BOLD, 18)); // TODO: css
  bonusText.setStroke(Color.BLACK);
  bonusText.setFill(Color.RED);
  
  StackPane.setAlignment( bonusText, Pos.TOP_LEFT);
  StackPane.setMargin( bonusText, new Insets( 20,0,00,18));
  
  getChildren().add( bonusText);

  
 }
 
 public void updateUI() {
  
  // update health bar
  healthBar.setValue( player.getRelativeHealth());
  
  // update score
  scoreText.setText(NumberFormat.getInstance().format( player.getScore()));
  
  // number of collected bonus items
  bonusText.setText( bonusFormatter.format( player.getBonusCollected()));
  
 }
 
}

Of course we'd use CSS for formatting the text, we'll schedule that for later. Another tricky thing is to get the bonus item image from the main class to this component, since it should be loaded in the general game loading mechanism. For a quick solution we hand the image over from the main class, but in the end we'll use an image manager for it.

What's left is to use these code parts in our main game code.
Image statisticsBonusImage;

private void loadResources() {
  ...
 // statistics icons
 statisticsBonusImage = new Image( getClass().getResource( "assets/statistics/stroyent.png").toExternalForm());
 ...
}
 
    
private void createLevel( Scene scene) {
 
 // load game assets
 loadResources();
 
 // create level structure
 createBackground();
 
 // create player, including input controls
 Player player = createPlayer( scene);

 // create player statistics overlay
 createStatistics( player);
}

private void createStatistics( Player player) {

 // create player statistics overlay
 Statistics statistics = new Statistics(player, statisticsBonusImage);
 
 // stretch statistics overlay width to full scene width
 statistics.prefWidthProperty().bind( primaryStage.getScene().widthProperty());
 
 // add to UI
 statisticsLayer.getChildren().add( statistics);

 // add to managed list
 statisticsList.add( statistics);
}    


private void createGameLoop() {
 
      gameLoop = new AnimationTimer() {
       
          @Override
          public void handle(long l) {
            ...
           // update score, bonus, etc
           // ---------------------------
           updateStatisticsOverlay();
           ...
          }

      };
      
}

That was rather easy and quickly done. Here's a screenshot.



With this mechanism we can always exchange the statistics component against another component which shows the statistics of multiple players. Or we simply relocate the display elements of the Statistics component depending on some layout parameter, e. g. player 1 statistics are on top left, player 2 statistics on top right.


Anansi: Background Scrolling - Revisited


Our background scrolling consists currently of an ImageView that's being relocated in the game loop. The whole ImageView. The relevant code for this:

public class Background extends SpriteBase {

 public Background(Pane layer, Image image, double x, double y, double speed) {
  super(layer, image, x, y, 0, 0, speed, 0, Double.MAX_VALUE, 0);
 }

 public void move() {
  
  super.move();
  
  checkBounds();
  
 }

 private void checkBounds() {
  
     // check bounds. we scroll upwards, so the y position is negative. once it's > 0 we have reached the end of the map and stop scrolling
     if( Double.compare( y, 0) >= 0) {
      y = 0;
     }
     
 }
 
 @Override
 public void checkRemovability() {
  // nothing to do
 }


}

When we take a look at the ImageView class, there's a method setViewPort. We could as well use that one. We only have to modify a few lines of code:

public class Background extends SpriteBase {

 public Background(Pane layer, Image image, double x, double y, double speed) {
  super(layer, image, x, y, 0, 0, speed, 0, Double.MAX_VALUE, 0);
  
  // we relocate to origin, the scrolling is done via setting the viewport
  getView().relocate(0, 0);
 }

 public void move() {
  
  super.move();
  
  checkBounds();
  
 }

 private void checkBounds() {
  
     // check bounds. we scroll upwards, so the y position is negative. once it's > 0 we have reached the end of the map and stop scrolling
     if( Double.compare( y, 0) >= 0) {
      y = 0;
     }
     
 }
 
 @Override
 public void checkRemovability() {
  // nothing to do
 }


 public void updateUI() {
  
  imageView.setViewport( new Rectangle2D( 0, -y, Settings.SCENE_WIDTH, Settings.SCENE_HEIGHT));

 }
}


In other words: Instead of relocating the ImageView in the updateUI method of the super class, we simply change the view port.

This gives us exactly the same visual result. It'll be interesting though how the internals of JavaFX work and what differences there are regarding performance in numbers. I couldn't see any performance gain or drop by using either of the solutions.