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.


No comments:

Post a Comment