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