Someone asked on StackOverflow How do I move a sprite across a screen? I thought I'd help and create a minimalistic engine from our Anansi engine. It demonstates the necessary areas that you need to cover when you create a game.
This blog post is a recap of what we've learned so far.
We need a main class in which we load the game, create the scene and which contains the game loop for
- input check
- move player sprites
- create and move AI sprites
- check sprite collision
- update sprites in the UI
- update score
public class Game extends Application { Random rnd = new Random(); Pane playfieldLayer; Pane scoreLayer; Image playerImage; Image enemyImage; List<Player> players = new ArrayList<>(); List<Enemy> enemies = new ArrayList<>(); Text collisionText = new Text(); boolean collision = false; Scene scene; @Override public void start(Stage primaryStage) { Group root = new Group(); // create layers playfieldLayer = new Pane(); scoreLayer = new Pane(); 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 spawnEnemies( true); // movement players.forEach(sprite -> sprite.move()); enemies.forEach(sprite -> sprite.move()); // check collisions checkCollisions(); // update sprites in scene players.forEach(sprite -> sprite.updateUI()); enemies.forEach(sprite -> sprite.updateUI()); // check if sprite can be removed enemies.forEach(sprite -> sprite.checkRemovability()); // remove removables from list, layer, etc removeSprites( enemies); // update score, health, etc updateScore(); } }; gameLoop.start(); } private void loadGame() { playerImage = new Image( getClass().getResource("player.png").toExternalForm()); enemyImage = new Image( getClass().getResource("enemy.png").toExternalForm()); } private void createScoreLayer() { collisionText.setFont( Font.font( null, FontWeight.BOLD, 64)); collisionText.setStroke(Color.BLACK); collisionText.setFill(Color.RED); scoreLayer.getChildren().add( collisionText); // TODO: quick-hack to ensure the text is centered; usually you don't have that; instead you have a health bar on top collisionText.setText("Collision"); double x = (Settings.SCENE_WIDTH - collisionText.getBoundsInLocal().getWidth()) / 2; double y = (Settings.SCENE_HEIGHT - collisionText.getBoundsInLocal().getHeight()) / 2; collisionText.relocate(x, y); collisionText.setText(""); collisionText.setBoundsType(TextBoundsType.VISUAL); } private void createPlayers() { // player input Input input = new Input( scene); // register input listeners input.addListeners(); // TODO: remove listeners on game over Image image = playerImage; // center horizontally, position at 70% vertically double x = (Settings.SCENE_WIDTH - image.getWidth()) / 2.0; double y = Settings.SCENE_HEIGHT * 0.7; // create player Player player = new Player(playfieldLayer, image, x, y, 0, 0, 0, 0, Settings.PLAYER_HEALTH, 0, Settings.PLAYER_SPEED, input); // register player players.add( player); } private void spawnEnemies( boolean random) { if( random && rnd.nextInt(Settings.ENEMY_SPAWN_RANDOMNESS) != 0) { return; } // image Image image = enemyImage; // random speed double speed = rnd.nextDouble() * 1.0 + 2.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 double x = rnd.nextDouble() * (Settings.SCENE_WIDTH - image.getWidth()); double y = -image.getHeight(); // create a sprite Enemy enemy = new Enemy( playfieldLayer, image, x, y, 0, 0, speed, 0, 1,1); // manage sprite enemies.add( enemy); } 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() { collision = false; for( Player player: players) { for( Enemy enemy: enemies) { if( player.collidesWith(enemy)) { collision = true; } } } } private void updateScore() { if( collision) { collisionText.setText("Collision"); } else { collisionText.setText(""); } } public static void main(String[] args) { launch(args); } }
Then we need a base class for sprites. The class unifies the common mechanisms that every movable object on the screen has. The sprites have attributes like
- layout (usually an image)
- position and rotation
- health
- damage they can cause
We provide methods for accessing and modifying these attributes. Specific changes are overridden in the Subclasses like Player and Enemy.
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; 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 abstract void checkRemovability(); }
Every sprite has some kind of AI. The player sprite's AI is the user, we evaluate the user's input.
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 // allow half of the ship to be outside of the screen playerShipMinX = 0 - image.getWidth() / 2.0; playerShipMaxX = Settings.SCENE_WIDTH - image.getWidth() / 2.0; playerShipMinY = 0 - image.getHeight() / 2.0; playerShipMaxY = Settings.SCENE_HEIGHT -image.getHeight() / 2.0; } 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(); } private 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; } } }
The enemy sprites in our simple engine just move from one direction to the other.
public class Enemy extends SpriteBase { public Enemy(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); } @Override public void checkRemovability() { if( Double.compare( getY(), Settings.SCENE_HEIGHT) > 0) { setRemovable(true); } } }
Then we need an input mechanism. We create a dedicated class for that so that we can easily create various instances in case we'd like to create a multiplayer game.
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()); } }
And then we need some special settings in which we store the attributes of our game.
public class Settings { public static double SCENE_WIDTH = 400; public static double SCENE_HEIGHT = 800; public static double PLAYER_SPEED = 4.0; public static double PLAYER_HEALTH = 100.0; public static int ENEMY_SPAWN_RANDOMNESS = 100; }
You can use any image for the sprites. I took mine from Wikipedia:
player.png
enemy.png
We can now put all the classes and images together into a single Java package and start the main class Game.java.
The gameplay is simple: The Zombie sprites are scrolling down from top to bottom, you have to evade them by moving the Smiley sprite via the cursor keys. A "Collision" text appears when the player sprite collides with the enemy sprites. The collision detection is a simple intersection check of the rectangular area of the sprites. For the final game one could consider a per-pixel collision detection.
Here's a screenshot of how it looks like:
Have fun! I hope this helps some of you in starting to create your own game.
Great tutorial bud, thanks a lot. I have written a few games in java and java 8, currently following your tutorial and all worked fine until i got to the top-down scrolling tutorial. I am getting a number of errors at runtime so the code will not run but compiles fine, can you show your imports for each class?
ReplyDeleteThanks,
Kurt