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 :-)