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();
}