There's a lot of code we can reuse and 2 things that we basically needed to change:
- the input is now via mouse
- the towers should rotate towards their enemies and fire
The code in this blog is an extended version of the one I posted on StackOverflow. You can use whatever images you like. This is just a prototype, so I took a brief look at some sites and I found nice ones at lostgarden. These awesome images make our game look very nice:
Since we are just extending our previously created engine a bit, I'll only post the main things here and the full code at the end of this blog.
The easy part: Instead of the keyboard input we use a mouse listener with which we place an instance of our tower:
// add event handler to create towers playfieldLayer.addEventFilter(MouseEvent.MOUSE_CLICKED, e -> { createTower(e.getX(), e.getY()); });and we create the tower as we would do any other player
private void createTower( double x, double y) { Image image = playerImage; // center image at position x -= image.getWidth() / 2; y -= image.getHeight() / 2; // create player Tower player = new Tower(playfieldLayer, image, x, y, 180, 0, 0, 0, Settings.PLAYER_SHIP_HEALTH, 0, Settings.PLAYER_SHIP_SPEED); // register player towers.add( player); }
There's nothing special to it. In your final game you'll probably want to place the towers on a pre-defined grid.
The more interesting part is to find the target for each tower and rotate towards it. The finding mechanism was already covered in the Anansi blog. A target can be found when it's within search range. Once it's within search range, we want to rotate the tower towards the target. And once the tower is rotated towards the target and the tower's guns can hit the target because it is within firing range, we want to fire at the target. Usually we do an animation (we already learned how to do that in a previous blog post) when firing, but I simply change the color to red in order to indicate some action of the guns. I'll leave the bullet part out for sake of simplicity.
So coming back to our code, we need to find a target that is within search range:
public void findTarget( List targetList) { // we already have a target if( getTarget() != null) { return; } SpriteBase closestTarget = null; double closestDistance = 0.0; for (SpriteBase target: targetList) { if (!target.isAlive()) continue; //get distance between follower and target double distanceX = target.getCenterX() - getCenterX(); double distanceY = target.getCenterY() - getCenterY(); //get total distance as one number double distanceTotal = Math.sqrt(distanceX * distanceX + distanceY * distanceY); // check if enemy is within range if( Double.compare( distanceTotal, targetRange) > 0) { continue; } if (closestTarget == null) { closestTarget = target; closestDistance = distanceTotal; } else if (Double.compare(distanceTotal, closestDistance) < 0) { closestTarget = target; closestDistance = distanceTotal; } } setTarget(closestTarget); }
The movement of the towers is reduced to rotation. We don't want to instantly rotate to the target, instead we'd like to rotate smoothly to it, with some kind of speed limitation. The problem when coding this kind of things is that the angle isn't a full 360 degree circle, instead it's a range from -180 to 180. So we have to consider this. The common problem is that e. g. if the tower faces top/right and the enemy moves down, then once the angle becomes negative (i. e. -180 degrees), then the tower would normally rotate the long way, i. e. clockwise towards the target. So we have to consider this, we want the short way, not the long way. A post in LostInActionScript helped solve the problem.
public void move() { SpriteBase follower = this; // reset within firing range withinFiringRange = false; // rotate towards target if( target != null) { // calculate rotation angle; follower must rotate towards the target // we need to consider the angle ranges from -180..180 by transforming the coordinates to a range of 0..360 and checking the values // the calculation is done in degrees double xDist = target.getCenterX() - follower.getCenterX(); double yDist = target.getCenterY() - follower.getCenterY(); double angleToTarget = Math.atan2(yDist, xDist) - Math.PI / 2; // -Math.PI / 2 because our sprite faces downwards double targetAngle = Math.toDegrees( angleToTarget); double currentAngle = follower.r; // check current angle range if( Math.abs(currentAngle) > 360) { if( currentAngle < 0) { currentAngle = currentAngle % 360 + 360; } else { currentAngle = currentAngle % 360; } } // calculate angle difference between follower and target double diff = targetAngle - currentAngle; // normalize the target angle if( Math.abs( diff) < 180) { // values within range => ok } else { if( diff > 0) { targetAngle -= 360; } else { targetAngle += 360; } } // get the angle between follower and target diff = targetAngle - currentAngle; // add the differnce angle to the current angle while considering easing when rotation comes closer to the target point currentAngle = currentAngle + diff / roatationEasing; // apply rotation follower.r = currentAngle; // determine if the player ship is within firing range; currently if the player ship is within 10 degrees (-10..+10) withinFiringRange = Math.abs( this.targetAngle-this.currentAngle) < 20; } super.move(); }
Well, that's basically it. If you'd like to see it in action, here's a video:
This is looking very nice. However, for a proper tower defense game the enemies need to follow a given path. It'll be interesting to find out how to implement that. A frequently used algorithm for that is the A* algorithm. Well, but first we'll have to find out how the A* algorithm works. So this is to-be-continued in another blog post.
And here's the source code for the interested ones.
Game.java
package game.towerdefense; import game.towerdefense.sprites.Enemy; import game.towerdefense.sprites.SpriteBase; import game.towerdefense.sprites.Tower; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.Random; import javafx.animation.AnimationTimer; import javafx.application.Application; import javafx.geometry.Point2D; import javafx.scene.Group; import javafx.scene.Scene; import javafx.scene.effect.DropShadow; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.input.MouseEvent; import javafx.scene.layout.Pane; import javafx.scene.paint.Color; import javafx.scene.text.Font; import javafx.scene.text.FontWeight; import javafx.scene.text.Text; import javafx.scene.text.TextBoundsType; import javafx.scene.transform.Scale; import javafx.stage.Stage; public class Game extends Application { Random rnd = new Random(); Pane backgroundLayer; Pane playfieldLayer; Pane scoreLayer; Image backgroundImage; Image playerImage; Image enemyImage; List<Tower> towers = new ArrayList<>();; List<Enemy> enemies = new ArrayList<>();; Text scoreText = new Text(); int score = 0; Scene scene; @Override public void start(Stage primaryStage) { Group root = new Group(); // create layers backgroundLayer = new Pane(); playfieldLayer = new Pane(); scoreLayer = new Pane(); root.getChildren().add( backgroundLayer); root.getChildren().add( playfieldLayer); root.getChildren().add( scoreLayer); // ensure the playfield size so that we can click on it playfieldLayer.setPrefSize( Settings.SCENE_WIDTH, Settings.SCENE_HEIGHT); // add event handler to create towers playfieldLayer.addEventFilter(MouseEvent.MOUSE_CLICKED, e -> { createTower(e.getX(), e.getY()); }); scene = new Scene( root, Settings.SCENE_WIDTH, Settings.SCENE_HEIGHT); primaryStage.setScene( scene); // fullscreen primaryStage.setFullScreen( Settings.STAGE_FULLSCREEN); primaryStage.setFullScreenExitHint(""); // scale by factor of 2 (in settings we have half-hd) to get proper dimensions in fullscreen (full-hd) if( primaryStage.isFullScreen()) { Scale scale = new Scale( Settings.STAGE_FULLSCREEN_SCALE, Settings.STAGE_FULLSCREEN_SCALE); scale.setPivotX(0); scale.setPivotY(0); scene.getRoot().getTransforms().setAll(scale); } primaryStage.show(); loadGame(); createBackgroundLayer(); createPlayfieldLayer(); createScoreLayer(); createTowers(); AnimationTimer gameLoop = new AnimationTimer() { @Override public void handle(long now) { // add random enemies spawnEnemies( true); // check if target is still valid towers.forEach( tower -> tower.checkTarget()); // tower movement: find target for( Tower tower: towers) { tower.findTarget( enemies); } // movement towers.forEach(sprite -> sprite.move()); enemies.forEach(sprite -> sprite.move()); // check collisions checkCollisions(); // update sprites in scene towers.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() { backgroundImage = new Image( getClass().getResource("images/background.png").toExternalForm()); playerImage = new Image( getClass().getResource("images/tower.png").toExternalForm()); enemyImage = new Image( getClass().getResource("images/ship.png").toExternalForm()); } private void createBackgroundLayer() { ImageView background = new ImageView( backgroundImage); backgroundLayer.getChildren().add( background); } private void createPlayfieldLayer() { // shadow effect to show depth // setting it on the entire group/layer preserves the shadow angle even if the node son the layer are rotated DropShadow dropShadow = new DropShadow(); dropShadow.setRadius(5.0); dropShadow.setOffsetX(10.0); dropShadow.setOffsetY(10.0); playfieldLayer.setEffect(dropShadow); } private void createScoreLayer() { scoreText.setFont( Font.font( null, FontWeight.BOLD, 48)); scoreText.setStroke(Color.BLACK); scoreText.setFill(Color.RED); scoreLayer.getChildren().add( scoreText); scoreText.setText( String.valueOf( score)); double x = (Settings.SCENE_WIDTH - scoreText.getBoundsInLocal().getWidth()) / 2; double y = 0; scoreText.relocate(x, y); scoreText.setBoundsType(TextBoundsType.VISUAL); } private void createTowers() { // position initial towers List<Point2D> towerPositionList = new ArrayList<>(); // towerPositionList.add(new Point2D( 100, 200)); // towerPositionList.add(new Point2D( 100, 400)); // towerPositionList.add(new Point2D( 1160, 200)); // towerPositionList.add(new Point2D( 1160, 600)); for( Point2D pos: towerPositionList) { createTower( pos.getX(), pos.getY()); } } private void createTower( double x, double y) { Image image = playerImage; // center image at position x -= image.getWidth() / 2; y -= image.getHeight() / 2; // create player Tower player = new Tower(playfieldLayer, image, x, y, 180, 0, 0, 0, Settings.PLAYER_SHIP_HEALTH, 0, Settings.PLAYER_SHIP_SPEED); // register player towers.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 trench, 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 trenchMinX; // left pixel pos of trench double trenchMaxX; // right pixel pos of trench // 2 waves: 0 = left, 1 = right if( rnd.nextInt(2) == 0) { trenchMinX = 220; // left pixel pos trenchMaxX = 530; // right pixel pos } else { trenchMinX = 760; // left pixel pos trenchMaxX = 1050; // right pixel pos } double x = trenchMinX + rnd.nextDouble() * (trenchMaxX - trenchMinX - 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() { for( Tower tower: towers) { for( Enemy enemy: enemies) { if( tower.hitsTarget( enemy)) { enemy.getDamagedBy( tower); // TODO: explosion if( !enemy.isAlive()) { enemy.setRemovable(true); // increase score score++; } } } } } private void updateScore() { scoreText.setText( String.valueOf( score)); } public static void main(String[] args) { launch(args); } }
Settings.java
package game.towerdefense; public class Settings { // fullscreen or windowed mode public static boolean STAGE_FULLSCREEN = false; // scale by factor of 2 (in settings we have half-hd) to get proper dimensions in fullscreen (full-hd) public static double STAGE_FULLSCREEN_SCALE = 2; public static double SCENE_WIDTH = 1280; public static double SCENE_HEIGHT = 720; public static double TOWER_DAMAGE = 1; // distance within which a tower can lock on to an enemy public static double TOWER_RANGE = 400; public static double PLAYER_SHIP_SPEED = 4.0; public static double PLAYER_SHIP_HEALTH = 100.0; public static int ENEMY_HEALTH = 200; public static int ENEMY_SPAWN_RANDOMNESS = 30; }
HealthBar.java
package game.towerdefense.ui; import javafx.scene.layout.Pane; import javafx.scene.paint.Color; import javafx.scene.shape.Rectangle; import javafx.scene.shape.StrokeType; public class HealthBar extends Pane { Rectangle outerHealthRect; Rectangle innerHealthRect; public HealthBar() { double height = 10; double outerWidth = 60; double innerWidth = 40; double x=0.0; double y=0.0; outerHealthRect = new Rectangle( x, y, outerWidth, height); outerHealthRect.setStroke(Color.BLACK); outerHealthRect.setStrokeWidth(2); outerHealthRect.setStrokeType( StrokeType.OUTSIDE); outerHealthRect.setFill(Color.RED); innerHealthRect = new Rectangle( x, y, innerWidth, height); innerHealthRect.setStrokeType( StrokeType.OUTSIDE); innerHealthRect.setFill(Color.LIMEGREEN); getChildren().addAll( outerHealthRect, innerHealthRect); } public void setValue( double value) { innerHealthRect.setWidth( outerHealthRect.getWidth() * value); } }
Tower.java
package game.towerdefense.sprites; import game.towerdefense.Settings; import java.util.List; import javafx.scene.effect.ColorAdjust; import javafx.scene.image.Image; import javafx.scene.layout.Pane; public class Tower extends SpriteBase { SpriteBase target; // TODO: use weakreference double turnRate = 0.6; double speed; double targetRange = Settings.TOWER_RANGE; // distance within which a tower can lock on to an enemy ColorAdjust colorAdjust; double rotationLimitDeg=0; double rotationLimitRad = Math.toDegrees( this.rotationLimitDeg); double roatationEasing = 10; // the higher the value, the slower the rotation double targetAngle = 0; double currentAngle = 0; boolean withinFiringRange = false; public Tower(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); this.speed = speed; this.currentAngle = Math.toRadians(r); this.setDamage(Settings.TOWER_DAMAGE); init(); } private void init() { // red colorization colorAdjust = new ColorAdjust(); colorAdjust.setContrast(0.0); colorAdjust.setHue(0.8); } @Override public void move() { SpriteBase follower = this; // reset within firing range withinFiringRange = false; // rotate towards target if( target != null) { // calculate rotation angle; follower must rotate towards the target // we need to consider the angle ranges from -180..180 by transforming the coordinates to a range of 0..360 and checking the values // the calculation is done in degrees double xDist = target.getCenterX() - follower.getCenterX(); double yDist = target.getCenterY() - follower.getCenterY(); double angleToTarget = Math.atan2(yDist, xDist) - Math.PI / 2; // -Math.PI / 2 because our sprite faces downwards double targetAngle = Math.toDegrees( angleToTarget); double currentAngle = follower.r; // check current angle range if( Math.abs(currentAngle) > 360) { if( currentAngle < 0) { currentAngle = currentAngle % 360 + 360; } else { currentAngle = currentAngle % 360; } } // calculate angle difference between follower and target double diff = targetAngle - currentAngle; // normalize the target angle if( Math.abs( diff) < 180) { // values within range => ok } else { if( diff > 0) { targetAngle -= 360; } else { targetAngle += 360; } } // get the angle between follower and target diff = targetAngle - currentAngle; // add the differnce angle to the current angle while considering easing when rotation comes closer to the target point currentAngle = currentAngle + diff / roatationEasing; // apply rotation follower.r = currentAngle; // determine if the player ship is within firing range; currently if the player ship is within 10 degrees (-10..+10) withinFiringRange = Math.abs( this.targetAngle-this.currentAngle) < 20; } super.move(); } public void checkTarget() { if( target == null) { return; } if( !target.isAlive() || target.isRemovable()) { setTarget( null); return; } //get distance between follower and target double distanceX = target.getCenterX() - getCenterX(); double distanceY = target.getCenterY() - getCenterY(); //get total distance as one number double distanceTotal = Math.sqrt(distanceX * distanceX + distanceY * distanceY); if( Double.compare( distanceTotal, targetRange) > 0) { setTarget( null); } } public void findTarget( List<? extends SpriteBase> targetList) { // we already have a target if( getTarget() != null) { return; } SpriteBase closestTarget = null; double closestDistance = 0.0; for (SpriteBase target: targetList) { if (!target.isAlive()) continue; //get distance between follower and target double distanceX = target.getCenterX() - getCenterX(); double distanceY = target.getCenterY() - getCenterY(); //get total distance as one number double distanceTotal = Math.sqrt(distanceX * distanceX + distanceY * distanceY); // check if enemy is within range if( Double.compare( distanceTotal, targetRange) > 0) { continue; } if (closestTarget == null) { closestTarget = target; closestDistance = distanceTotal; } else if (Double.compare(distanceTotal, closestDistance) < 0) { closestTarget = target; closestDistance = distanceTotal; } } setTarget(closestTarget); } public SpriteBase getTarget() { return target; } public void setTarget(SpriteBase target) { this.target = target; } @Override public void checkRemovability() { if( Double.compare( health, 0) < 0) { setTarget(null); setRemovable(true); } } public boolean hitsTarget( SpriteBase enemy) { return target == enemy && withinFiringRange; } public void updateUI() { // change effect (color/shadow) depending on whether we're firing or not if( withinFiringRange) { imageView.setEffect(colorAdjust); } else { imageView.setEffect(null); } super.updateUI(); } }
Enemy.java
package game.towerdefense.sprites; import game.towerdefense.Settings; import game.towerdefense.ui.HealthBar; import javafx.scene.image.Image; import javafx.scene.layout.Pane; public class Enemy extends SpriteBase { HealthBar healthBar; double healthMax; 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); healthMax = Settings.ENEMY_HEALTH; setHealth(healthMax); init(); } private void init() { } @Override public void checkRemovability() { if( Double.compare( getY(), Settings.SCENE_HEIGHT) > 0) { setRemovable(true); } } public void addToLayer() { super.addToLayer(); // create health bar; has to be created here because addToLayer is called in super constructor // and it wouldn't exist yet if we'd create it as class member healthBar = new HealthBar(); this.layer.getChildren().add(this.healthBar); } public void removeFromLayer() { super.removeFromLayer(); this.layer.getChildren().remove(this.healthBar); } /** * Health as a value from 0 to 1. * @return */ public double getRelativeHealth() { return getHealth() / healthMax; } public void updateUI() { super.updateUI(); // update health bar healthBar.setValue( getRelativeHealth()); // locate healthbar above enemy, centered horizontally healthBar.relocate(x + (imageView.getBoundsInLocal().getWidth() - healthBar.getBoundsInLocal().getWidth()) / 2, y - healthBar.getBoundsInLocal().getHeight() - 4); } }
SpriteBase.java
package game.towerdefense.sprites; 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; 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(); }And you may want the sprites for testing. Here they are, but you can use whatever you prefer. Just put them into the ./game/towerdefense/images folder of your project.
tower.png
ship.png
background.png
Hi RolandC ,
ReplyDeleteI have issues with testing the above software ...It's a javaFX application but how many packages are they ? There are 6 different java Classes , are they all in the same package ? And is there another package for the images ?
Greetings
For simplicity I uploaded a more recent code to github. You can get the project from here:
ReplyDeletehttps://github.com/Roland09/TowerDefenseTest
Just create a new JavaFX project and copy/paste the source in there.
I don't have time currently to continue development. I'd like to combine the AStar code and the Particle code with the Tower Defense Code. I'd be happy to see the results if anyone got the time to do it.
Have fun & keep on coding :-)