Friday, May 15, 2015

Simple Game Engine: Pong in JavaFX

Someone asked on StackOverflow about how to make the keyboard controls work in his game of Pong. That reminded me that I've never created that classic game. So I thought I'd give it a shot with our simple game engine.

Pong is a simple game in which two players try to keep a ball in the game by moving their paddles. In this blog we just create the base in which:

  • a ball moves around the screen
  • the ball bounces off the paddles
  • a player can control a paddle
  • another paddle is controlled by AI

The game loop consists of

  • processing player input
  • add the balls
  • move the paddles and the balls
  • check ball for paddle collision
  • update ui
  • remove sprites, e. g. if ball is outside scene
  • update score

So all in all we have
AnimationTimer gameLoop = new AnimationTimer() {

   @Override
   public void handle(long now) {
    
    // player input
    players.forEach(sprite -> sprite.processInput());

             // add random enemies
             spawnBalls();
             
    // movement
    players.forEach(sprite -> sprite.move());
    balls.forEach(sprite -> sprite.move());

    // check collisions
    checkCollisions();
    
    // update sprites in scene
    players.forEach(sprite -> sprite.updateUI());
    balls.forEach(sprite -> sprite.updateUI());
    
             // check if sprite can be removed
    balls.forEach(sprite -> sprite.checkRemovability());
             
             // remove removables from list, layer, etc
             removeSprites( balls);
             
             // update score, health, etc
             updateScore();
   }
   
  };
We keep the game in retro style, i. e. simple. There's really no need to use image files when we create the images ourselves. Here's a technique that may interest some of you. We simply create a node and then put a snapshot of it into an image:
 private void loadGame() {
  
  WritableImage wi;
  
  // create paddle image
  // -------------------------
  double w = Settings.PADDLE_WIDTH;
  double h = Settings.PADDLE_HEIGHT;
  
  Rectangle rect = new Rectangle( w, h);
  
  wi = new WritableImage( (int) w, (int) h);
  rect.snapshot(null, wi);
  
  playerImage = wi;
  enemyImage = wi;
  
  // create ball image
  // -------------------------
  double r = Settings.BALL_RADIUS;
  
  Circle circle = new Circle( r);
  
  wi = new WritableImage( (int) r * 2, (int) r * 2);
  circle.snapshot(null, wi);
  
  ballImage = wi;
 
 }
This blog is only supposed to be a start for your own game, so we keep it simple. The ball is merely bouncing off in horizontal direction. In the end one should consider the ball's position on the paddle and the paddle movement speed and calculate some bouncing angle.
public class Ball extends SpriteBase {

        ...
 public void bounceOff( SpriteBase sprite) {
  
  // TODO: consider angle
  dx = -dx;
 }
 
 @Override
 public void checkRemovability() {

  if( Double.compare( getX(), 0) < 0 || Double.compare( getX(), Settings.SCENE_WIDTH) > 0 || Double.compare( getY(), 0) < 0 || Double.compare( getY(), Settings.SCENE_HEIGHT) > 0) {
   setRemovable(true);
  }

  
 }
}
Similarly we keep the enemy simple by moving the paddle vertically. For the game one would have to consider the incoming ball's angle, determine the impact position and calculate the vertical movement of the paddle.
public class Enemy extends Player {

 private enum Direction {
  UP,
  DOWN;
 }
 
 Direction direction = Direction.UP;
        
        ... 

 @Override
 public void processInput() {
  
  if( direction == Direction.UP) {
   dy = -speed;
  } else {
   dy = speed;
  }
  
 }
 
 @Override
 protected void checkBounds() {

  super.checkBounds();
  
  if( y == playerShipMinY) {
   direction = Direction.DOWN;
  } else if( y == playerShipMaxY) {
   direction = Direction.UP;
  }

 }
}
The rest of the code is similar to what we've learned so far. We use a base class for the sprites, an input class, evaluate input in the player class, show and update score, etc.

Here's a screenshot of what the game looks like:



And here's the full source if you'd like to try it out for yourself and enhance it:

Game.java
package game.pong;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Random;

import javafx.animation.AnimationTimer;
import javafx.application.Application;
import javafx.scene.Group;
import javafx.scene.Scene;
import javafx.scene.image.Image;
import javafx.scene.image.WritableImage;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.Pane;
import javafx.scene.shape.Circle;
import javafx.scene.shape.Rectangle;
import javafx.scene.text.Font;
import javafx.scene.text.FontWeight;
import javafx.scene.text.Text;
import javafx.stage.Stage;

public class Game extends Application {

 Random rnd = new Random();
 
 Pane playfieldLayer;
 AnchorPane scoreLayer;
 
 Image playerImage;
 Image enemyImage;
 Image ballImage;
 
 List<Player> players = new ArrayList<>();
 List<Ball> balls = new ArrayList<>();
 
 Text playerScoreText = new Text();
 Text enemyScoreText = new Text();
 
 Map<Player,Text> scoreDisplay = new HashMap<>();
 
 Scene scene;
 
 @Override
 public void start(Stage primaryStage) {
  
  Group root = new Group();
  
  // create layers
  playfieldLayer = new Pane();
  playfieldLayer.setPrefSize(Settings.SCENE_WIDTH, Settings.SCENE_HEIGHT);
  
  scoreLayer = new AnchorPane();
  scoreLayer.setPrefSize(Settings.SCENE_WIDTH, Settings.SCENE_HEIGHT);
  
  root.getChildren().add( playfieldLayer);
  root.getChildren().add( scoreLayer);
  
  scene = new Scene( root, Settings.SCENE_WIDTH, Settings.SCENE_HEIGHT);
  
  primaryStage.setScene( scene);
  primaryStage.show();
  
  loadGame();
  
  createScoreLayer();
  createPlayers();
  
  AnimationTimer gameLoop = new AnimationTimer() {

   @Override
   public void handle(long now) {
    
    // player input
    players.forEach(sprite -> sprite.processInput());

             // add random enemies
             spawnBalls();
             
    // movement
    players.forEach(sprite -> sprite.move());
    balls.forEach(sprite -> sprite.move());

    // check collisions
    checkCollisions();
    
    // update sprites in scene
    players.forEach(sprite -> sprite.updateUI());
    balls.forEach(sprite -> sprite.updateUI());
    
             // check if sprite can be removed
    balls.forEach(sprite -> sprite.checkRemovability());
             
             // remove removables from list, layer, etc
             removeSprites( balls);
             
             // update score, health, etc
             updateScore();
   }
   
  };
  gameLoop.start();
  
 }
 
 private void loadGame() {
  
  WritableImage wi;
  
  // create paddle image
  // -------------------------
  double w = Settings.PADDLE_WIDTH;
  double h = Settings.PADDLE_HEIGHT;
  
  Rectangle rect = new Rectangle( w, h);
  
  wi = new WritableImage( (int) w, (int) h);
  rect.snapshot(null, wi);
  
  playerImage = wi;
  enemyImage = wi;
  
  // create ball image
  // -------------------------
  double r = Settings.BALL_RADIUS;
  
  Circle circle = new Circle( r);
  
  wi = new WritableImage( (int) r * 2, (int) r * 2);
  circle.snapshot(null, wi);
  
  ballImage = wi;
 
 }

 private void createScoreLayer() {
  
  playerScoreText.setFont( Font.font( null, FontWeight.BOLD, 32));
  enemyScoreText.setFont( Font.font( null, FontWeight.BOLD, 32));

  AnchorPane.setTopAnchor(playerScoreText, 0.0);
  AnchorPane.setLeftAnchor(playerScoreText, 10.0);

  AnchorPane.setTopAnchor(enemyScoreText, 0.0);
  AnchorPane.setRightAnchor(enemyScoreText, 10.0);

  scoreLayer.getChildren().add( playerScoreText);
  scoreLayer.getChildren().add( enemyScoreText);
  
 }
 
 private void createPlayers() {

  // create player instances
  Player player = createPlayer();
  Player enemy = createEnemy();
  
  // register player
  players.add( player);
  players.add( enemy);
  
  // assign score display
  scoreDisplay.put(player, playerScoreText);
  scoreDisplay.put(enemy, enemyScoreText);
   
 }
 
 private Player createPlayer() {

  // player input
  Input input = new Input( scene);
  
  // register input listeners
  input.addListeners(); // TODO: remove listeners on game over

  Image image = playerImage;
  
  // offset x position, center vertically
  double x = Settings.PADDLE_OFFSET_X;
  double y = (Settings.SCENE_HEIGHT - image.getHeight()) * 0.5;
  
  // create player
  Player player = new Player(playfieldLayer, image, x, y, 0, 0, 0, 0, 1, 0, Settings.PADDLE_SPEED, input);

  return player;
  
 }
 
 private Player createEnemy() {
  
  Image image = enemyImage;
  
  // offset x position, center vertically
  double x = Settings.SCENE_WIDTH - Settings.PADDLE_OFFSET_X - image.getWidth();
  double y = (Settings.SCENE_HEIGHT - image.getHeight()) * 0.5;
  
  // create player
  Enemy player = new Enemy(playfieldLayer, image, x, y, 0, 0, 0, 0, 1, 0, Settings.PADDLE_SPEED);
  
  return player;
  
 }
 
 private void spawnBalls() {
  if( balls.size() == 0) {
   createBall();
  }
 }
 private void createBall() {
  
  Image image = ballImage;
  
  // offset x position, center vertically
  double x = (Settings.SCENE_WIDTH - image.getWidth()) / 2;
  double y = (Settings.SCENE_HEIGHT - image.getHeight()) / 2;
  
  // create ball
  Ball ball = new Ball( playfieldLayer, image, x, y, 0, -Settings.BALL_SPEED, 0, 0, 1, 1);
  
  // register ball
  balls.add(ball);
   
 }

 private void removeSprites(  List<? extends SpriteBase> spriteList) {
  Iterator<? extends SpriteBase> iter = spriteList.iterator();
     while( iter.hasNext()) {
      SpriteBase sprite = iter.next();
      
      if( sprite.isRemovable()) {
       
       // remove from layer
       sprite.removeFromLayer();
       
       // remove from list
       iter.remove();
      }
     }
 }
 
 private void checkCollisions() {
  
  for( Player player: players) {
   for( Ball ball: balls) {
    if( player.collidesWith(ball)) {
     
     // bounce ball
     ball.bounceOff(player);
     
     // add score
     // TODO: proper score handling: score only to the player if the ball leaves screen afterwards
     player.addScore( 1); 
     
    }
   }
  }
 }
 
 private void updateScore() {
  
  for( Player player: players) {
   scoreDisplay.get( player).setText( "" + (int) player.getScore());    
  }

 }
 
 public static void main(String[] args) {
  launch(args);
 }
  
}
Ball.java
package game.pong;

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

public class Ball extends SpriteBase {

 public Ball(Pane layer, Image image, double x, double y, double r, double dx, double dy, double dr, double health, double damage) {
  super(layer, image, x, y, r, dx, dy, dr, health, damage);
 }

 public void bounceOff( SpriteBase sprite) {
  
  // TODO: ensure the ball doesn't get stuck inside a player
  dx = -dx;
 }
 
 @Override
 public void checkRemovability() {

  if( Double.compare( getX(), 0) < 0 || Double.compare( getX(), Settings.SCENE_WIDTH) > 0 || Double.compare( getY(), 0) < 0 || Double.compare( getY(), Settings.SCENE_HEIGHT) > 0) {
   setRemovable(true);
  }

  
 }
}
Enemy.java
package game.pong;

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

public class Enemy extends Player {

 private enum Direction {
  UP,
  DOWN;
 }
 
 Direction direction = Direction.UP;
 
 public Enemy(Pane layer, Image image, double x, double y, double r, double dx, double dy, double dr, double health, double damage, double speed) {
  super(layer, image, x, y, r, dx, dy, dr, health, damage, speed, null);
 }

 @Override
 public void processInput() {
  
  if( direction == Direction.UP) {
   dy = -speed;
  } else {
   dy = speed;
  }
  
 }
 
 @Override
 protected void checkBounds() {

  super.checkBounds();
  
  if( y == playerShipMinY) {
   direction = Direction.DOWN;
  } else if( y == playerShipMaxY) {
   direction = Direction.UP;
  }

 }
}
Player.java
package game.pong;

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

public class Player extends SpriteBase {

 double playerShipMinX;
 double playerShipMaxX;
 double playerShipMinY;
 double playerShipMaxY;

 Input input;
 
 double speed;
 
 public Player(Pane layer, Image image, double x, double y, double r, double dx, double dy, double dr, double health, double damage, double speed, Input input) {

  super(layer, image, x, y, r, dx, dy, dr, health, damage);

  this.speed = speed;
  this.input = input;
  
  init();
 }

 
 private void init() {
  
  // calculate movement bounds of the player ship
  playerShipMinX = x; // limit to vertical movement 
  playerShipMaxX = x; // limit to vertical movement 
  playerShipMinY = 0;
  playerShipMaxY = Settings.SCENE_HEIGHT -image.getHeight();
  
 }

 public void processInput() {
  
  // ------------------------------------
  // movement
  // ------------------------------------
  
     // vertical direction
     if( input.isMoveUp()) {
      dy = -speed;
     } else if( input.isMoveDown()) {
      dy = speed;
     } else {
      dy = 0d;
     }
     
     // horizontal direction
     if( input.isMoveLeft()) {
      dx = -speed;
     } else if( input.isMoveRight()) {
      dx = speed;
     } else {
      dx = 0d;
     }

 }
 
 @Override
 public void move() {
  
  super.move();
  
  // ensure the ship can't move outside of the screen
  checkBounds();
  
     
 }
 
 protected void checkBounds() {

     // vertical
     if( Double.compare( y, playerShipMinY) < 0) {
      y = playerShipMinY;
     } else if( Double.compare(y, playerShipMaxY) > 0) {
      y = playerShipMaxY;
     }

     // horizontal
     if( Double.compare( x, playerShipMinX) < 0) {
      x = playerShipMinX;
     } else if( Double.compare(x, playerShipMaxX) > 0) {
      x = playerShipMaxX;
     }

 }


 @Override
 public void checkRemovability() {
 }
 
}
Settings.java
package game.pong;


public class Settings {

 public static double SCENE_WIDTH = 600;
 public static double SCENE_HEIGHT = 400;

 public static double PADDLE_WIDTH = 20;
 public static double PADDLE_HEIGHT = 100;

 public static double BALL_RADIUS = 10;

 public static double PADDLE_OFFSET_X = 50;
 
 public static double PADDLE_SPEED = 4.0;
 public static double BALL_SPEED = 4.0;
 
}
Input.java
package game.pong;

import java.util.BitSet;

import javafx.event.EventHandler;
import javafx.scene.Scene;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;

public class Input {

    /**
     * Bitset which registers if any {@link KeyCode} keeps being pressed or if it is released.
     */
    private BitSet keyboardBitSet = new BitSet();

    // -------------------------------------------------
    // default key codes
    // will vary when you let the user customize the key codes or when you add support for a 2nd player
    // -------------------------------------------------

    private KeyCode upKey = KeyCode.UP;
    private KeyCode downKey = KeyCode.DOWN;
    private KeyCode leftKey = KeyCode.LEFT;
    private KeyCode rightKey = KeyCode.RIGHT;
    private KeyCode primaryWeaponKey = KeyCode.SPACE;
    private KeyCode secondaryWeaponKey = KeyCode.CONTROL;

    Scene scene;

    public Input( Scene scene) {
        this.scene = scene;
    }

    public void addListeners() {

        scene.addEventFilter(KeyEvent.KEY_PRESSED, keyPressedEventHandler);
        scene.addEventFilter(KeyEvent.KEY_RELEASED, keyReleasedEventHandler);

    }

    public void removeListeners() {

        scene.removeEventFilter(KeyEvent.KEY_PRESSED, keyPressedEventHandler);
        scene.removeEventFilter(KeyEvent.KEY_RELEASED, keyReleasedEventHandler);

    }

    /**
     * "Key Pressed" handler for all input events: register pressed key in the bitset
     */
    private EventHandler<KeyEvent> keyPressedEventHandler = new EventHandler<KeyEvent>() {
        @Override
        public void handle(KeyEvent event) {

            // register key down
            keyboardBitSet.set(event.getCode().ordinal(), true);

        }
    };

    /**
     * "Key Released" handler for all input events: unregister released key in the bitset
     */
    private EventHandler<KeyEvent> keyReleasedEventHandler = new EventHandler<KeyEvent>() {
        @Override
        public void handle(KeyEvent event) {

            // register key up
            keyboardBitSet.set(event.getCode().ordinal(), false);

        }
    };


    // -------------------------------------------------
    // Evaluate bitset of pressed keys and return the player input.
    // If direction and its opposite direction are pressed simultaneously, then the direction isn't handled.
    // -------------------------------------------------

    public boolean isMoveUp() {
        return keyboardBitSet.get( upKey.ordinal()) && !keyboardBitSet.get( downKey.ordinal());
    }

    public boolean isMoveDown() {
        return keyboardBitSet.get( downKey.ordinal()) && !keyboardBitSet.get( upKey.ordinal());
    }

    public boolean isMoveLeft() {
        return keyboardBitSet.get( leftKey.ordinal()) && !keyboardBitSet.get( rightKey.ordinal());  
    }

    public boolean isMoveRight() {
        return keyboardBitSet.get( rightKey.ordinal()) && !keyboardBitSet.get( leftKey.ordinal());
    }

    public boolean isFirePrimaryWeapon() {
        return keyboardBitSet.get( primaryWeaponKey.ordinal());
    }

    public boolean isFireSecondaryWeapon() {
        return keyboardBitSet.get( secondaryWeaponKey.ordinal());
    }

}
SpriteBase.java
package game.pong;

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

public abstract class SpriteBase {

 Image image;
 ImageView imageView;

 Pane layer;

 double x;
 double y;
 double r;

 double dx;
 double dy;
 double dr;

 double health;
 double damage;

 boolean removable = false;

 double w;
 double h;

 boolean canMove = true;
 
 double score = 0;
 
 public SpriteBase(Pane layer, Image image, double x, double y, double r, double dx, double dy, double dr, double health, double damage) {

  this.layer = layer;
  this.image = image;
  this.x = x;
  this.y = y;
  this.r = r;
  this.dx = dx;
  this.dy = dy;
  this.dr = dr;

  this.health = health;
  this.damage = damage;

  this.imageView = new ImageView(image);
  this.imageView.relocate(x, y);
  this.imageView.setRotate(r);

  this.w = image.getWidth(); // imageView.getBoundsInParent().getWidth();
  this.h = image.getHeight(); // imageView.getBoundsInParent().getHeight();

  addToLayer();

 }

 public void addToLayer() {
  this.layer.getChildren().add(this.imageView);
 }

 public void removeFromLayer() {
  this.layer.getChildren().remove(this.imageView);
 }

 public Pane getLayer() {
  return layer;
 }

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

 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 getR() {
  return r;
 }

 public void setR(double r) {
  this.r = r;
 }

 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 double getDr() {
  return dr;
 }

 public void setDr(double dr) {
  this.dr = dr;
 }

 public double getHealth() {
  return health;
 }

 public double getDamage() {
  return damage;
 }

 public void setDamage(double damage) {
  this.damage = damage;
 }

 public void setHealth(double health) {
  this.health = health;
 }

 public boolean isRemovable() {
  return removable;
 }

 public void setRemovable(boolean removable) {
  this.removable = removable;
 }

 public void move() {

  if( !canMove)
   return;
  
  x += dx;
  y += dy;
  r += dr;

 }

 public boolean isAlive() {
  return Double.compare(health, 0) > 0;
 }

 public ImageView getView() {
  return imageView;
 }

 public void updateUI() {

  imageView.relocate(x, y);
  imageView.setRotate(r);

 }

 public double getWidth() {
  return w;
 }

 public double getHeight() {
  return h;
 }

 public double getCenterX() {
  return x + w * 0.5;
 }

 public double getCenterY() {
  return y + h * 0.5;
 }

 // TODO: per-pixel-collision
 public boolean collidesWith( SpriteBase otherSprite) {
  
  return ( otherSprite.x + otherSprite.w >= x && otherSprite.y + otherSprite.h >= y && otherSprite.x <= x + w && otherSprite.y <= y + h);
  
 }
 
 /**
  * Reduce health by the amount of damage that the given sprite can inflict
  * @param sprite
  */
 public void getDamagedBy( SpriteBase sprite) {
  health -= sprite.getDamage();
 }
 
 /**
  * Set health to 0
  */
 public void kill() {
  setHealth( 0);
 }
 
 /**
  * Set flag that the sprite can be removed from the UI.
  */
 public void remove() {
  setRemovable(true);
 }
 
 /**
  * Set flag that the sprite can't move anymore.
  */
 public void stopMovement() {
  this.canMove = false;
 }

 public void addScore( double value) {
  this.score += value;
 }
 
 public double getScore() {
  return score;
 }
 public abstract void checkRemovability();

}

1 comment:

  1. Your game doesn't work. If you hit the ball with the player on its top or bottom, you continously get points. Also you don't get points for hitting the ball, only if the ball passes your enemy!

    ReplyDelete