Friday, January 23, 2015

Anansi: Layers


Our game will have various layers to show some kind of depth. The layers are in this order:

  • scrolling background
  • lower clouds
  • bullets
  • playfield (player, enemies)
  • upper clouds
  • score
  • debug information

The code for this is straightforward:

Pane backgroundLayer;
Pane lowerCloudLayer;
Pane bulletLayer;
Pane playfieldLayer;
Pane upperCloudLayer;
Pane debugLayer;

@Override
public void start(Stage primaryStage) {
 
 this.primaryStage = primaryStage;
 
 try {

  // create root node
  Group root = new Group();
  
  // create layers
  backgroundLayer = new Pane();
  lowerCloudLayer = new Pane();
  bulletLayer = new Pane();
  playfieldLayer = new Pane();
  upperCloudLayer = new Pane();
  debugLayer = new Pane();
  
  // add layers to scene root
  root.getChildren().add( backgroundLayer);
  root.getChildren().add( lowerCloudLayer);
  root.getChildren().add( bulletLayer);
  root.getChildren().add( playfieldLayer);
  root.getChildren().add( upperCloudLayer);
  root.getChildren().add( debugLayer);

  // create scene
  Scene scene = new Scene( root, Settings.SCENE_WIDTH, Settings.SCENE_HEIGHT, Color.BLACK);
  
  // show stage
  primaryStage.setScene(scene);
  primaryStage.show();
  
  ...
  
  // start the game
  startGame();
  
 } catch(Exception e) {
  e.printStackTrace();
 }
}
 
We introduced a special Settings class so that we can change the game's attributes at one place.
public class Settings {

 public static double SCENE_WIDTH = 400;
 public static double SCENE_HEIGHT = 800;
 
}

This way it's easy to vary the scene dimensions depending on your playfield size.

Anansi: The Game Loop - Revisited


We decided to go with the AnimationTimer. It runs smooth at fixed 60fps and does what we want.

It should be possible for a game loop to

  • start
  • stop
  • pause
  • resume

The AnimationTimer doesn't have a pause flag, so we'll add a simple boolean to indicate the state.

Here's what we need:

private AnimationTimer gameLoop;
private boolean gamePaused = false;
...
private void startGame() {
 
      gameLoop.start();
      
}

private void pauseGame() {

 gamePaused = true;
 
 gameLoop.stop();
 
}

private void resumeGame() {

 gamePaused = false;

 gameLoop.start();
 
}

private void stopGame() {
 
 // TODO: remove event handlers (player, etc)
 
}

private void createGameLoop() {
 
  gameLoop = new AnimationTimer() {
   
      @Override
      public void handle(long l) {
       
        // player AI (input)
       
        // sprite AI

        // add sprites (clouds, enemies, bullets, missiles)

        // move sprites internally

        // move sprites in the UI

        // check if sprites can be removed (eg collsion, off-screen)

        // update debug information    

      }

  };
      
} 

That's about it. We'll add other parts like sprite collision once the basic engine is complete.

Anansi: Chapter 2: From Prototype To Game


It's finally weekend, time to continue with the hobby. We've seen that JavaFX gives us nice means to create a 2D vertically scrolling Shoot'em'up. What we do now is to find common denominators, unify the code from the prototype and extend it.

Here's a summary of what we need:

  • game loop
  • resource loading mechanism
  • input (keyboard in our case)
  • layers
  • sprites
  • sprite management (move, update ui, explode, artificial intelligence)
  • utilities (debug information, screenshot)

There would be some things to consider which we'll do later. I don't want to confuse you with an unnecessary complexity. For now. For a full game we'd need e. g.

  • a level manager for multiple levels
  • a resource manager 
  • a sprite manager
  • multi-player support
  • ...  

If we structure the code in regards to these requirements, it will be easy to add them later. For now we want a single player game with 1 Level, however one with diversity. Rewriting the prototype should be done within a few hours. So let's get coding!

Tuesday, January 20, 2015

Anansi: Chapter 1 Conclusion


We now have the answers to the initial questions:

  • Can we implement a 2D vertically scrolling shoot'em'up with JavaFX?

    Yes, that's definitely possible.
  • How easy is it to create a game? I prefer Rapid Application Development.

    I did the prototype within a few hours. I'd say given some JavaFX experience it's a very good option to create something from scratch and get a quick result.
  • Do we need 3rd party libraries or is JavaFX sufficient?

    So far we use no 3rd party libraries. And I'd like to keep it this way. The less dependencies the better. Who knows, maybe we port it Android. I've read that it would be possible to run JavaFX on mobile devices. I haven't done it yet and I have no information about how these ports perform, but it's something I'll most certainly try.
  • How does the game perform with a lot of stuff happening on the screen?

    The prototype performs very well with a lot of sprites being on the screen simultaneously. Of course we have to expect much more stuff happening on the screen as we code along, but from what I've seen so far to me it's a GO to continue.
  • The amount of time to create the prototype.

    Creating the prototype took a few hours. We were very well below 1000 lines of code which is very good. The less code the better. I even think that creating a game like that would be a candidate for a "Creating a game in 24 hours or less" article.


I'd say we're done with the prototype and can get serious with our game. I'm looking forward to see what we'll get ... let's keep on coding! :-)


Sunday, January 18, 2015

Anansi: Screenshots


I'd like to get rid of the border around the screenshots of our game. Until now I did them with windows print screen key. JavaFX has a built-in snapshot command. So let's use that one.

Here's a small utility class that I created using a tutorial on the Oracle website.
package game.utils;

import java.io.File;
import java.io.IOException;

import javafx.embed.swing.SwingFXUtils;
import javafx.scene.image.WritableImage;
import javafx.stage.FileChooser;
import javafx.stage.Stage;

import javax.imageio.ImageIO;

public class Utils {

 /**
  * Take a screenshot of the scene in the given stage, open file save dialog and save it.
  * @param stage
  */
 public static void screenshot( Stage stage) {
  
  // take screenshot
     WritableImage image = stage.getScene().snapshot( null);

     // create file save dialog
     FileChooser fileChooser = new FileChooser();
     
     // title
        fileChooser.setTitle("Save Image");
        
        // initial directory
        fileChooser.setInitialDirectory(
                new File(System.getProperty("user.home"))
            );              
        
        // extension filter
        fileChooser.getExtensionFilters().addAll(
            // new FileChooser.ExtensionFilter("All Images", "*.*"),
            // new FileChooser.ExtensionFilter("JPG", "*.jpg"),
            new FileChooser.ExtensionFilter("PNG", "*.png")
        );
        
        // show dialog
        File file = fileChooser.showSaveDialog( stage);
        if (file != null) {
         
            try {
             
                // save file
                ImageIO.write(SwingFXUtils.fromFXImage(image, null), "png", file);
                
            } catch (IOException ex) {
             
                System.err.println(ex.getMessage());
                
            }
        }
 }
 
}

We can use it by extending our KEY_RELEASED event handler. The utility method demands a stage as parameter, so we need to keep a reference of the primary stage in a global variable.
Stage primaryStage;
 
@Override
public void start(Stage primaryStage) {

    this.primaryStage = primaryStage;
    ...

}
         

The screenshot key will be F12.
...
 @Override
 public void handle(KeyEvent event) {
 
  // register key up
  keyboardBitSet.set(event.getCode().ordinal(), false);
 
  // take screenshot, open save dialog and save it
  if( event.getCode() == KeyCode.F12) {
   
   // pause game
   gameLoop.stop();
   
   // save screenshot
   Utils.screenshot( primaryStage);
  
   // resume game
   gameLoop.start();
  
  }
 
 }
...
And now we got nice PNG files without the windows border whenever we press the screenshot key:

Anansi: Enemies


Adding enemies is very easy with the mechanism we have. It's similar to the code in which we add the clouds.

In the original game the alien ship was called a "Tormentor". I took a screenshot of it and use it in the game. Of course as usual you can pick whatever image you prefer. If you'd like to use a plane, simply replace the one in our code with it.

Speaking of code, we create the image for the enemy and load it.

Image tormentor;
...
// enemy ship: tormentor
tormentor = new Image( getClass().getResource( "assets/vehicles/tormentor.png").toExternalForm());

And then we add the enemy at random intervals in our game loop.
// add enemies
// --------------------------
// add enemies at random intervals
if( rnd.nextInt(100) == 0) {
 
  // create sprite
  ImageView enemy = new ImageView( tormentor);

  // random speed
  double speed = rnd.nextDouble() * 1.0 + 1.0;
 
  // x position range: enemy is always fully inside the screen, no part of it is outside
  // y position: right on top of the view, so that it becomes visible with the next game iteration
  Sprite sprite = new Sprite( playfieldLayer, enemy, rnd.nextDouble() * (SCENE_WIDTH - enemy.getImage().getWidth()), -enemy.getImage().getHeight(), 0, speed);
  sprites.add( sprite);

}

The game with the enemies looks like this:


You can clearly distinguish the various layers we have.

Now we're eager to see how it all performs with lots of enemies. It all depends on the power of your machine. I reduced the randomness interval from 100 to 10 and got this still running at 60 FPS:


Even reducing it to 1 and spawning an enemy at every frame didn't cause the FPS to drop. Very awesome!



But let's not get ahead of ourselves. There are still a few things to do which may cost performance, one of them being that we'd like to shoot the enemy and see an explosion.

Code-wise I think we can certainly continue. In the intermediary summary section you've seen the current code base. It's not nice to have it all in one game loop. Moreover our sprites belong to different categories and we should make proper objects of them. It's time for refactoring in one of the next lessons.

Anansi: The Story So Far ...


We've achieved quite a lot in these few hours of happy coding. I have to admit that i'm still very impressed with JavaFX. That's a lot of functionality for this relatively little amount of code.



Here's what we got so far:

Main.java
package game;
 
import java.util.ArrayList;
import java.util.BitSet;
import java.util.Iterator;
import java.util.List;
import java.util.Random;

import javafx.animation.AnimationTimer;
import javafx.application.Application;
import javafx.event.EventHandler;
import javafx.scene.Group;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.Pane;
import javafx.scene.paint.Color;
import javafx.stage.Stage;


public class Main extends Application {
 
 private double SCENE_WIDTH = 400;
 private double SCENE_HEIGHT = 800;
 
 Random rnd = new Random();
 
 private AnimationTimer gameLoop;

 ImageView backgroundImageView;
 ImageView playerShip;
 Image playerBullet;
 
 double playerShipSpeed = 4.0;
 double playerShipDeltaX = 0.0;
 double playerShipDeltaY = 0.0;
 
 double backgroundScrollSpeed = 0.6;
 
 Pane backgroundLayer;
 Pane lowerCloudLayer;
 Pane bulletLayer;
 Pane playfieldLayer;
 Pane upperCloudLayer;
 Pane debugLayer;
 
 Label debugLabel;
 
 // note: ordinal may be not appropriate, but we don't have a method in keycode to get the int code, so it'll have to do
 BitSet keyboardBitSet = new BitSet();
 
 // counter for game loop
 int frameCount = 0;
 int fpsCurrent = 0;
 long prevTime = -1;
 
 // list of available cloud images
 Image clouds[];
 
 List sprites = new ArrayList();
 
 double cannonChargeTime = 6; // the cannon can fire every n frames 
 double cannonChargeCounter = cannonChargeTime; // initially the cannon is charged
 double cannonChargeCounterDelta = 1; // counter is increased by this value each frame 
 
 double cannonBullets = 5; // number of bullets which the cannon can fire in 1 shot (center, left, right)
 double cannonBulletSpread = 0.6; // dx of left and right bullets
 double cannonBulletSpeed = 8.0; // speed of each bullet
 
 @Override
 public void start(Stage primaryStage) {
  try {

   // create root node
   Group root = new Group();

   // create layers
   backgroundLayer = new Pane();
   lowerCloudLayer = new Pane();
   bulletLayer = new Pane();
   playfieldLayer = new Pane();
   upperCloudLayer = new Pane();
   debugLayer = new Pane();
   
   // add layers to scene root
   root.getChildren().add( backgroundLayer);
   root.getChildren().add( lowerCloudLayer);
   root.getChildren().add( bulletLayer);
   root.getChildren().add( playfieldLayer);
   root.getChildren().add( upperCloudLayer);
   root.getChildren().add( debugLayer);
   
   // create scene
   Scene scene = new Scene( root, SCENE_WIDTH,SCENE_HEIGHT);
   
   // show stage
   primaryStage.setScene(scene);
   primaryStage.show();
   
   // load game assets
   loadGame();

   // add nodes which display debug information
   addDebugInformation();

   // keyboard control
   addInputControls( scene);
   
   // start the game
   startGameLoop();
   
  } catch(Exception e) {
   e.printStackTrace();
  }
 }
 
 private void addDebugInformation() {
  
  debugLabel = new Label();
  debugLabel.setTextFill(Color.RED);
  debugLayer.getChildren().add( debugLabel);
  
 }

 private void addInputControls( Scene scene) {
  
  // keyboard handler: key pressed
  scene.addEventFilter(KeyEvent.KEY_PRESSED, new EventHandler() {
   @Override
   public void handle(KeyEvent event) {
    
    keyboardBitSet.set(event.getCode().ordinal(), true);
    
   }
  });
  
        // keyboard handler: key up
  scene.addEventFilter(KeyEvent.KEY_RELEASED, new EventHandler() {
   @Override
   public void handle(KeyEvent event) {
    
    keyboardBitSet.set(event.getCode().ordinal(), false);
    
   }
  });
  
 }
 
 private void loadGame() {
  
  // background
  // --------------------------------
  backgroundImageView = new ImageView( getClass().getResource( "assets/maps/canyon.jpg").toExternalForm());
  
  // reposition the map. it is scrolling from bottom of the background to top of the background
  backgroundImageView.relocate( 0, -backgroundImageView.getImage().getHeight() + SCENE_HEIGHT);
  
  // add background to layer
  backgroundLayer.getChildren().add( backgroundImageView);
  
  // player ship
  // --------------------------------
  playerShip = new ImageView( getClass().getResource( "assets/vehicles/anansi.png").toExternalForm());
  
  // center horizontally, position at 70% vertically
  playerShip.relocate( (SCENE_WIDTH - playerShip.getImage().getWidth()) / 2.0, SCENE_HEIGHT * 0.7);
  
  // add to playfield layer
  playfieldLayer.getChildren().add( playerShip);
  
  // clouds
  // --------------------------------
  clouds = new Image[4];
  clouds[0] = new Image( getClass().getResource( "assets/environment/cloud-01.png").toExternalForm());
  clouds[1] = new Image( getClass().getResource( "assets/environment/cloud-02.png").toExternalForm());
  clouds[2] = new Image( getClass().getResource( "assets/environment/cloud-03.png").toExternalForm());
  clouds[3] = new Image( getClass().getResource( "assets/environment/cloud-04.png").toExternalForm());
  
  // bullets
  playerBullet = new Image( getClass().getResource( "assets/bullets/bullet_01.png").toExternalForm());
 }
 
 private void startGameLoop() {

  // calculate movement bounds of the player ship
  // allow half of the ship to be outside of the screen 
  double playerShipMinX = 0 - playerShip.getImage().getWidth() / 2.0;
  double playerShipMaxX = SCENE_WIDTH - playerShip.getImage().getWidth() / 2.0;
  double playerShipMinY = 0 - playerShip.getImage().getHeight() / 2.0;
  double playerShipMaxY = SCENE_HEIGHT - playerShip.getImage().getHeight() / 2.0;
  
  // game loop
        gameLoop = new AnimationTimer() {
         
            @Override
            public void handle(long l) {
            
             // get keyboard input
             // ---------------------------
             // note: ordinal may be not appropriate, but we don't have an method in keycode to get the int code, so it'll have to do

             // evaluate keyboard events
             boolean isUpPressed = keyboardBitSet.get(KeyCode.UP.ordinal()); 
             boolean isDownPressed = keyboardBitSet.get(KeyCode.DOWN.ordinal()); 
             boolean isLeftPressed = keyboardBitSet.get(KeyCode.LEFT.ordinal()); 
             boolean isRightPressed = keyboardBitSet.get(KeyCode.RIGHT.ordinal());
             boolean isSpacePressed = keyboardBitSet.get(KeyCode.SPACE.ordinal());
             boolean isControlPressed = keyboardBitSet.get(KeyCode.CONTROL.ordinal());
             
             // vertical direction
             if( isUpPressed && !isDownPressed) {
              playerShipDeltaY = -playerShipSpeed;
             } else if( !isUpPressed && isDownPressed) {
              playerShipDeltaY = playerShipSpeed;
             } else {
              playerShipDeltaY = 0d;
             }
             
             // horizontal direction
             if( isLeftPressed && !isRightPressed) {
              playerShipDeltaX = -playerShipSpeed;
             } else if( !isLeftPressed && isRightPressed) {
              playerShipDeltaX = playerShipSpeed;
             } else {
              playerShipDeltaX = 0d;
             }
             
             // scroll background
             // ---------------------------
             // calculate new position
             double y = backgroundImageView.getLayoutY() + backgroundScrollSpeed;
             
             // 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;
             }

             // move background
             backgroundImageView.setLayoutY( y);

             // move player ship
             // ---------------------------
             double newX = playerShip.getLayoutX() + playerShipDeltaX;
             double newY = playerShip.getLayoutY() + playerShipDeltaY;
             
             // check bounds
             // vertical
             if( Double.compare( newY, playerShipMinY) < 0) {
              newY = playerShipMinY;
             } else if( Double.compare(newY, playerShipMaxY) > 0) {
              newY = playerShipMaxY;
             }

             // horizontal
             if( Double.compare( newX, playerShipMinX) < 0) {
              newX = playerShipMinX;
             } else if( Double.compare(newX, playerShipMaxX) > 0) {
              newX = playerShipMaxX;
             }
             
             playerShip.setLayoutX( newX);
             playerShip.setLayoutY( newY);

             // limit bullet fire
             // ---------------------------
             // charge player cannon: increase a counter by some delta. once it reaches a limit, the cannon is considered charged
             cannonChargeCounter += cannonChargeCounterDelta;
             if( cannonChargeCounter > cannonChargeTime) {
              cannonChargeCounter = cannonChargeTime;
             }
             
             // fire player bullets
             // ---------------------------
             boolean isCannonCharged = cannonChargeCounter >= cannonChargeTime;
             if( isSpacePressed && isCannonCharged) {
              
              // x-position: center bullet on center of the ship
              double bulletX = playerShip.getLayoutX() + playerShip.getImage().getWidth() / 2.0 - playerBullet.getWidth() / 2.0;
              // y-position: let bullet come out on top of the ship
              double bulletY = playerShip.getLayoutY();

              // create sprite
              ImageView imgv = new ImageView( playerBullet);
              Sprite sprite = new Sprite( bulletLayer, imgv, bulletX, bulletY, 0, -cannonBulletSpeed);
              sprites.add( sprite);

              // left/right: vary x-axis position
              for( int i=0; i < cannonBullets / 2.0; i++) {

               // left
                  imgv = new ImageView( playerBullet);
                  sprite = new Sprite( bulletLayer, imgv, bulletX, bulletY, -cannonBulletSpread * i, -cannonBulletSpeed);
                  sprites.add( sprite);

               // right
                  imgv = new ImageView( playerBullet);
                  sprite = new Sprite( bulletLayer, imgv, bulletX, bulletY, cannonBulletSpread * i, -cannonBulletSpeed);
                  sprites.add( sprite);

              }
              
              // player bullet uncharged
              cannonChargeCounter = 0;
             }
             
             // check sprite visibility
             // remove every sprite that's not visible anymore
             // ---------------------------
             Iterator iter = sprites.iterator();
             while( iter.hasNext()) {
              
              Sprite sprite = iter.next();
              
              // check lower screen bounds
              if( sprite.getY() > SCENE_HEIGHT) {
               iter.remove();
               continue;
              }
              
              // check upper screen bounds
              if( (sprite.getY() + sprite.getImageView().getImage().getHeight()) < 0) {
               iter.remove();
               continue;
              }
              
              // check right screen bounds
              if( sprite.getX() > SCENE_WIDTH) {
               iter.remove();
               continue;
              }

              // check left screen bounds
              if( (sprite.getX() + sprite.getImageView().getImage().getWidth()) < 0) {
               iter.remove();
               continue;
              }

             }
             
             // move sprites
             // ---------------------------
             // move sprites internally
             for( Sprite sprite: sprites) {
              sprite.move();
             }
             
             // move sprites on screen
             for( Sprite sprite: sprites) {
              sprite.getImageView().relocate( sprite.getX(), sprite.getY());
             }
             
             // add random clouds
             // ---------------------------
             if( rnd.nextInt(100) == 0) {
              
              // determine random layer
              Pane layer;
              if( rnd.nextInt(2) == 0) {
               layer = lowerCloudLayer;
              } else {
               layer = upperCloudLayer;
              }

              // speed
              double speed = rnd.nextDouble() * 1.0 + 1.0;

              // determine random image
              Image image = clouds[ rnd.nextInt( clouds.length)]; 
              
              // create sprite
     ImageView imgv = new ImageView( image);
     
     // clouds in the upper layer are less opaque than the ones in the lower layer so that the player ship is always visible
     // and the upper layer clouds are faster
     if( layer == upperCloudLayer) {
      imgv.setOpacity(0.5);
      speed +=1.0;
     }
     
     // create a sprite and position it horizontally so that half of it may be outside of the view, vertically so that it enters the view with the next movement
     Sprite sprite = new Sprite( layer, imgv, rnd.nextDouble() * SCENE_WIDTH - imgv.getImage().getWidth() / 2.0, -imgv.getImage().getHeight(), 0, speed);
     sprites.add( sprite);
     
             }
             
             
             // calculate fps
             // ---------------------------
             frameCount++;
             
             long currTime = System.currentTimeMillis();
             
             if( currTime - prevTime >= 1000) {
              
              // get current fps
              fpsCurrent = frameCount;

              // reset counter every second
              prevTime = currTime;
              frameCount = 0;
             }
             
             // show debug info
             // ---------------------------
             debugLabel.setText("FPS: " + fpsCurrent + "\nSprites: " + sprites.size());
            }
 
        };
        
        gameLoop.start();
        
 }
 
 public static void main(String[] args) {
  launch(args);
 }
}


Sprite.java
package game;

import javafx.scene.image.ImageView;
import javafx.scene.layout.Pane;

public class Sprite {

 Pane layer;
 ImageView imageView;
 double x;
 double y;
 double dx;
 double dy;
 double rotation;
 
 boolean alive = true;
 
 public Sprite( Pane layer, ImageView imageView, double x, double y, double dx, double dy) {
  
  this.layer = layer;
  this.imageView = imageView;
  this.x = x;
  this.y = y;
  this.dx = dx;
  this.dy = dy;
  
  layer.getChildren().add( imageView);
  imageView.relocate(x, y);
 }
 
 public Pane getLayer() {
  return layer;
 }

 public void setLayer(Pane layer) {
  this.layer = layer;
 }

 public ImageView getImageView() {
  return imageView;
 }
 public void setImageView(ImageView imageView) {
  this.imageView = imageView;
 }
 public double getX() {
  return x;
 }
 public void setX(double x) {
  this.x = x;
 }
 public double getY() {
  return y;
 }
 public void setY(double y) {
  this.y = y;
 }
 public double getDx() {
  return dx;
 }
 public void setDx(double dx) {
  this.dx = dx;
 }
 public double getDy() {
  return dy;
 }
 public void setDy(double dy) {
  this.dy = dy;
 }

 public void move() {
  
  x += dx;
  y += dy;
  
 }
 
}
ps: does anyone know why blogspot changes "Sprite" to "sprite" and adds tags like "</sprite></keyevent></keyevent></sprite></sprite> " to the code parts?