Advertisement

Make a Neon Vector Shooter With jME: HUD and Black Holes

by
Student iconAre you a student? Get a yearly Tuts+ subscription for $45 →
This post is part of a series called Cross-Platform Vector Shooter: jMonkeyEngine.
Make a Neon Vector Shooter in jMonkeyEngine: Enemies and Sounds
Make a Neon Vector Shooter With jME: Particle Effects

So far, in this series about building a Geometry Wars-inspired game in jMonkeyEngine, we've implemented most of the gameplay and audio. In this part, we'll finish the gameplay by adding black holes, and we'll add some UI to display the players score.


Overview

Here's what we're working towards across the whole series:


...and here's what we'll have by the end of this part:


As well as modifying existing classes, we'll add two new ones:

  • BlackHoleControl: Needless to say, this will handle the behavior of our black holes.
  • Hud: Here we'll store and display the players score, lives, and other UI elements.

Let's start with the black holes.


Black Holes

The black hole is one of the most interesting enemies in Geometry Wars. In MonkeyBlaster, our clone, it's especially cool once we add particle effects and the warping grid in the next two chapters.

Basic Functionality

The black holes will pull in the player's ship, nearby enemies, and (after the next tutorial) particles, but will repel bullets.

There are many possible functions we can use for attraction or repulsion. The simplest is to use a constant force, so that the black hole pulls with the same strength regardless of the object's distance. Another option is to have the force increase linearly from zero, at some maximum distance, to full strength, for objects directly on top of the black hole. And if we'd like to model gravity more realistically, we can use the inverse square of the distance, which means the force of gravity is proportional to 1/(distance*distance).

We'll actually be using each of these three functions to handle different objects. The bullets will be repelled with a constant force, the enemies and the player's ship will be attracted with a linear force, and the particles will use an inverse square function.

Implementation

We'll start by spawning our black holes. To achieve that we need another varibale in MonkeyBlasterMain:

private long spawnCooldownBlackHole;

Next we need to declare a node for the black holes; let's call it blackHoleNode. You can declare and initialize it just like we did the enemyNode in the previous tutorial.

We'll also create a new method, spawnBlackHoles, which we call right after spawnEnemies in simpleUpdate(float tpf). The actual spawning is pretty similar to spawning enemies:

    private void spawnBlackHoles() {
        if (blackHoleNode.getQuantity() < 2) {              if (System.currentTimeMillis() - spawnCooldownBlackHole > 10f) {
                spawnCooldownBlackHole = System.currentTimeMillis();
                if (new Random().nextInt(1000) == 0) {
                    createBlackHole();
                }
            }
        }
    }

Creating the black hole follows our standard procedure as well:

    private void createBlackHole() {
        Spatial blackHole = getSpatial("Black Hole");
        blackHole.setLocalTranslation(getSpawnPosition());
        blackHole.addControl(new BlackHoleControl());
        blackHole.setUserData("active",false);
        blackHoleNode.attachChild(blackHole);
    }

Once again, we load the spatial, set its position, add a control, set it to non-active, and finally attach it to the appropriate node. When you take a look at BlackHoleControl, you'll notice that it's not much different either.

We'll implement the attraction and repulsion later, in MonkeyBlasterMain, but there is one thing we need to address now. Since the black hole is a strong enemy, we don't want it to go down easily. Therefore, we add a variable, hitpoints, to the BlackHoleControl, and set its initial value to 10 so that it'll die after ten hits.

public class BlackHoleControl extends AbstractControl {
    private long spawnTime;
    private int hitpoints;

    public BlackHoleControl() {
        spawnTime = System.currentTimeMillis();
        hitpoints = 10;
    }

    @Override
    protected void controlUpdate(float tpf) {
        if ((Boolean) spatial.getUserData("active")) {
//            we'll use this spot later...
        } else {
            // handle the "active"-status
            long dif = System.currentTimeMillis() - spawnTime;
            if (dif >= 1000f) {
                spatial.setUserData("active",true);
            }

            ColorRGBA color = new ColorRGBA(1,1,1,dif/1000f);
            Node spatialNode = (Node) spatial;
            Picture pic = (Picture) spatialNode.getChild("Black Hole");
            pic.getMaterial().setColor("Color",color);
        }

    }

    @Override
    protected void controlRender(RenderManager rm, ViewPort vp) {}

    public void wasShot() {
        hitpoints--;
    }

    public boolean isDead() {
        return hitpoints <= 0;
    }
}

We're nearly finished with the basic code for the black holes. Before we get to implementing the gravity, we have to take care of the collisions.

When the player or an enemy comes too close to the black hole, it'll die. But when a bullet manages to hit it, the black hole will lose one hitpoint.

Take a look at the following code. It belongs to handleCollisions(). It's basically the same as for all the other collisions:

        //is something colliding with a black hole?
        for (i=0; i<blackHoleNode.getQuantity(); i++) {
            Spatial blackHole = blackHoleNode.getChild(i);
            if ((Boolean) blackHole.getUserData("active")) {
                //player
                if (checkCollision(player,blackHole)) {
                    killPlayer();
                }

                //enemies
                int j=0;
                while (j < enemyNode.getQuantity()) {
                    if (checkCollision(enemyNode.getChild(j),blackHole)) {
                        enemyNode.detachChildAt(j);
                    }
                    j++;
                }

                //bullets
                j=0;
                while (j < bulletNode.getQuantity()) {
                    if (checkCollision(bulletNode.getChild(j),blackHole)) {
                        bulletNode.detachChildAt(j);
                        blackHole.getControl(BlackHoleControl.class).wasShot();
                        if (blackHole.getControl(BlackHoleControl.class).isDead()) {
                            blackHoleNode.detachChild(blackHole);
                            sound.explosion();
                        }
                    }
                    j++;
                }
            }
        }

Well, you can kill the black hole now, but thats not the only time when it should dissappear. Whenever the player dies, all enemies vanish and so should the black hole. To handle this, just add the following line to our killPlayer() method:

        blackHoleNode.detachAllChildren();

Now its time to implement the cool stuff. We'll create another method, handleGravity(float tpf). Just call it with the other methods in simplueUpdate(float tpf).

In this method, we check all entities (players, bullets and enemies) to see whether they near to a black hole—let's say within 250 pixels—and, if they are, we apply the appropriate effect:

    private void handleGravity(float tpf) {
        for (int i=0; i<blackHoleNode.getQuantity(); i++) {
            if (!(Boolean)blackHoleNode.getChild(i).getUserData("active")) {continue;}
            int radius = 250;

            //check Player
            if (isNearby(player,blackHoleNode.getChild(i),radius)) {
                applyGravity(blackHoleNode.getChild(i), player, tpf);
            }
            //check Bullets
            for (int j=0; j<bulletNode.getQuantity(); j++) {
                if (isNearby(bulletNode.getChild(j),blackHoleNode.getChild(i),radius)) {
                    applyGravity(blackHoleNode.getChild(i), bulletNode.getChild(j), tpf);
                }
            }
            //check Enemies
            for (int j=0; j<enemyNode.getQuantity(); j++) {
                if (!(Boolean)enemyNode.getChild(j).getUserData("active")) {continue;}
                if (isNearby(enemyNode.getChild(j),blackHoleNode.getChild(i),radius)) {
                    applyGravity(blackHoleNode.getChild(i), enemyNode.getChild(j), tpf);
                }
            }
        }
    }

To check whether two entities are within a certain distance of each other, we create a method called isNearby() which compares the locations of the two spatials:

    private boolean isNearby(Spatial a, Spatial b, float distance) {
        Vector3f pos1 = a.getLocalTranslation();
        Vector3f pos2 = b.getLocalTranslation();
        return pos1.distanceSquared(pos2) <= distance * distance;
    }

Now that we've checked each entity, if it is active and within the specified distance of a black hole we can finally apply the effect of the gravity. To do that, we'll make use of the controls: we create a method in each control, called applyGravity(Vector3f gravity).

Let's take a look at each of them:

PlayerControl:

    public void applyGravity(Vector3f gravity) {
        spatial.move(gravity);
    }

BulletControl:

    public void applyGravity(Vector3f gravity) {
        direction.addLocal(gravity);
    }

SeekerControl and WandererControl:

    public void applyGravity(Vector3f gravity) {
        velocity.addLocal(gravity);
    }

And now back to the main class, MonkeyBlasterMain. I'll give you the method first and explain the steps underneath it:

    private void applyGravity(Spatial blackHole, Spatial target, float tpf) {
        Vector3f difference = blackHole.getLocalTranslation().subtract(target.getLocalTranslation());

        Vector3f gravity = difference.normalize().multLocal(tpf);
        float distance = difference.length();

        if (target.getName().equals("Player")) {
            gravity.multLocal(250f/distance);
            target.getControl(PlayerControl.class).applyGravity(gravity.mult(80f));
        } else if (target.getName().equals("Bullet")) {
            gravity.multLocal(250f/distance);
            target.getControl(BulletControl.class).applyGravity(gravity.mult(-0.8f));
        } else if (target.getName().equals("Seeker")) {
            target.getControl(SeekerControl.class).applyGravity(gravity.mult(150000));
        } else if (target.getName().equals("Wanderer")) {
            target.getControl(WandererControl.class).applyGravity(gravity.mult(150000));
        }
    }

The first thing we do is calculate the Vector between the black hole and the target. Next, we calculate the gravitational force. The important thing to note is that we—once again—multiply the force by the time that has gone by since the last update, tpf, in order to achieve the same effect with every frame rate. Finally, we calculate the distance between the target and the black hole.

For each type of target, we need to apply the force in a slightly different way. For the player and for bullets, the force gets stronger the closer they are to the black hole:

gravity.multLocal(250f/distance);

Bullets need to be repelled; thats why we multiply their gravitational force by a negative number.

Seekers and Wanderers simply get a force applied thats always the same, regardless of their distance from the black hole.

We are now finished with the implementation of the black holes. We'll add some cool effects in the next chapters, but for now you can test it out!

Tip: Note that this is your game; feel free to modify any parameters you like! You can change the area of effect for the black hole, the speed of the enemies or the player... These things have a tremendous effect on the gameplay. Sometimes it's worth playing a bit with the values.

The Head-Up Display

There is some information that needs to be tracked and displayed to the player. Thats what the HUD (Head-Up Display) is there for. We want to track the players lives, the current score multiplier, and of course the score itself, and show all this to the player.

When the player scores 2,000 points (or 4,000, or 6,000, or ...) the player will get another life. Additionally, we want to save the score after each game and compare it to the current highscore. The multiplier increases every time the player kills an enemy and jumps back to one when the player doesn't kill anything in some time.

We'll create a new class for all that, called Hud. In Hud we have quite a few things to initialize right at the beginning:

public class Hud {
    private AssetManager assetManager;
    private Node guiNode;
    private int screenWidth,screenHeight;
    private final int fontSize = 30;

    private final int multiplierExpiryTime = 2000;
    private final int maxMultiplier = 25;

    public int lives;
    public int score;
    public int multiplier;

    private long multiplierActivationTime;
    private int scoreForExtraLife;

    private BitmapFont guiFont;
    private BitmapText livesText;
    private BitmapText scoreText;
    private BitmapText multiplierText;
    private Node gameOverNode;

    public Hud(AssetManager assetManager, Node guiNode, int screenWidth, int screenHeight) {
        this.assetManager = assetManager;
        this.guiNode = guiNode;
        this.screenWidth = screenWidth;
        this.screenHeight = screenHeight;
        setupText();
    }

That's quite a lot of variables, but most of them are pretty self-explanatory. We need to have a reference to the AssetManager to load text, to the guiNode to add it to the scene, and so on.

Next, there are a few variables we need to track continually, like the multiplier, its expiry time, the maximum possible multiplier, and the player's lives.

And finally we have some BitmapText objects, which store the actual text and display it on the screen. This text is set up in the method setupText(), which is called at the end of the constructor.

    private void setupText() {
        guiFont = assetManager.loadFont("Interface/Fonts/Default.fnt");

        livesText = new BitmapText(guiFont,false);
        livesText.setLocalTranslation(30,screenHeight-30,0);
        livesText.setSize(fontSize);
        livesText.setText("Lives: "+lives);
        guiNode.attachChild(livesText);

        scoreText = new BitmapText(guiFont, true);
        scoreText.setLocalTranslation(screenWidth - 200,screenHeight-30,0);
        scoreText.setSize(fontSize);
        scoreText.setText("Score: "+score);
        guiNode.attachChild(scoreText);

        multiplierText = new BitmapText(guiFont, true);
        multiplierText.setLocalTranslation(screenWidth-200,screenHeight-100,0);
        multiplierText.setSize(fontSize);
        multiplierText.setText("Multiplier: "+lives);
        guiNode.attachChild(multiplierText);
    }

In order to load text, we need to load the font first. In our example we use a default font that comes with the jMonkeyEngine.

Tip: Of course, you can create your own fonts, place them somewhere in the assets directory—preferably assets/Interface—and load them. If you want know more, check out this tutorial on loading fonts in jME.

Next, we'll need a method to reset all the values so that we can start over if the player dies too many times:

    public void reset() {
        score = 0;
        multiplier = 1;
        lives = 4;

        multiplierActivationTime = System.currentTimeMillis();
        scoreForExtraLife = 2000;
        updateHUD();
    }

Resetting the values is simple, but we also need to apply the changes of the variables to the HUD. We do that in a separate method:

    private void updateHUD() {
        livesText.setText("Lives: "+lives);
        scoreText.setText("Score: "+score);
        multiplierText.setText("Multiplier: "+multiplier);
    }

During the battle, the player gains points and loses lives. We'll call those methods from MonkeyBlasterMain:

    public void addPoints(int basePoints) {
        score += basePoints * multiplier;
        if (score >= scoreForExtraLife) {
            scoreForExtraLife += 2000;
            lives++;
        }
        increaseMultiplier();
        updateHUD();
    }

    private void increaseMultiplier() {
        multiplierActivationTime = System.currentTimeMillis();
        if (multiplier < maxMultiplier) {
            multiplier++;
        }
    }

    public boolean removeLife() {
        if (lives == 0) {return false;}
        lives--;
        updateHUD();
        return true;
    }

Notable concepts in those methods are:

  • Whenever we add points, we check whether we've already reached the necessary score to get an extra life.
  • Whenever we add points, we need to also increase the multiplier by calling a separate method.
  • Whenever we increase the multiplier, we need to be aware of the maximum possible multiplier and not go beyond that.
  • Whenever the player hits an enemy, we need to reset the multiplierActivationTime.
  • When the player has no lives left to be removed, we return false so that the main class can act accordingly.

There are two things left that we need to handle.

First, we need to reset the multiplier if the player doesn't kill anything for a while. We'll implement an update() method that checks whether it's time to do this:

    public void update() {
        if (multiplier > 1) {
            if (System.currentTimeMillis() - multiplierActivationTime > multiplierExpiryTime) {
               multiplier = 1;
               multiplierActivationTime = System.currentTimeMillis();
               updateHUD();
            }
        }
    }

The last thing we need to take care of is ending the game. When the player has used up all of their lives, the game is over and the final score should be displayed in the middle of the screen. We also need to check whether the current high score is lower than the player's current score and, if so, save the current score as the new high score. (Note that you need to create a file highscore.txt first, or you won't be able to load a score.)

This is how we end the game in Hud:

    public void endGame() {
        // init gameOverNode
        gameOverNode = new Node();
        gameOverNode.setLocalTranslation(screenWidth/2 - 180, screenHeight/2 + 100,0);
        guiNode.attachChild(gameOverNode);

        // check highscore
        int highscore = loadHighscore();
        if (score > highscore) {saveHighscore();}

        // init and display text
        BitmapText gameOverText = new BitmapText(guiFont, false);
        gameOverText.setLocalTranslation(0,0,0);
        gameOverText.setSize(fontSize);
        gameOverText.setText("Game Over");
        gameOverNode.attachChild(gameOverText);

        BitmapText yourScoreText = new BitmapText(guiFont, false);
        yourScoreText.setLocalTranslation(0,-50,0);
        yourScoreText.setSize(fontSize);
        yourScoreText.setText("Your Score: "+score);
        gameOverNode.attachChild(yourScoreText);

        BitmapText highscoreText = new BitmapText(guiFont, false);
        highscoreText.setLocalTranslation(0,-100,0);
        highscoreText.setSize(fontSize);
        highscoreText.setText("Highscore: "+highscore);
        gameOverNode.attachChild(highscoreText);
    }

Finally, we need two last methods: loadHighscore() and saveHighscore():

    private int loadHighscore() {
        try {
            FileReader fileReader = new FileReader(new File("highscore.txt"));
            BufferedReader reader = new BufferedReader(fileReader);
            String line = reader.readLine();
            return Integer.valueOf(line);
        } catch (FileNotFoundException e) {e.printStackTrace();
        } catch (IOException e) {e.printStackTrace();}
        return 0;
    }

    private void saveHighscore() {
        try {
            FileWriter writer = new FileWriter(new File("highscore.txt"),false);
            writer.write(score+System.getProperty("line.separator"));
            writer.close();
        } catch (IOException e) {e.printStackTrace();}
    }
Tip: As you may have noticed, I did not use the assetManager to load and save the text. We did use it for loading all the sounds and graphics, and the proper jME way to load and save texts actually is using the assetManager for it, but since it does not support text file loading on its own, we'd need to register a TextLoader with the assetManager. You can do that if you want, but in this tutorial I stuck to the default Java way of loading and saving text, for the sake of simplicity.

Now we have a big class that will handle all our HUD-related problems. The only thing we need to do now is add it to the game.

We need to declare the object at the start:

private Hud hud;

... initialize it in simpleInitApp():

        hud = new Hud(assetManager, guiNode, settings.getWidth(), settings.getHeight());
        hud.reset();

... update the HUD in simpleUpdate(float tpf) (regardless of whether the player is alive):

hud.update();

... add points when the player hits enemies (in checkCollisions()):

                    // add points depending on the type of enemy
                    if (enemyNode.getChild(i).getName().equals("Seeker")) {
                        hud.addPoints(2);
                    } else if (enemyNode.getChild(i).getName().equals("Wanderer")) {
                        hud.addPoints(1);
                    }
Watch out! You need to add the points before you detach the enemies from the scene, or you'll run into problems with enemyNode.getChild(i).

... and remove lives when the player dies (in killPlayer()):

        if (!hud.removeLife()) {
            hud.endGame();
            gameOver = true;
        }

You may have noticed that we introduced a new variable as well, gameOver. We'll set it to false at the beginning:

private boolean gameOver = false;

The player should not spawn any more once the game is over, so we add this condition to simpleUpdate(float tpf)

        } else if (System.currentTimeMillis() - (Long) player.getUserData("dieTime") > 4000f && !gameOver) {

Now you can start up the game and check whether you've missed something! And your game has got a new goal: beating the highscore. I wish you good luck!

Custom Cursor

Since we have a 2D game there is one more thing to add to perfect our HUD: a custom mouse cursor.
It's nothing special; just insert this line in simpleInitApp():

inputManager.setMouseCursor((JmeCursor) assetManager.loadAsset("Textures/Pointer.ico"));

Conclusion

The gameplay now is completely finished. In the remaining two parts of this series, we'll add some cool graphical effects. This will actually make the game slightly harder, since the enemies may not be as easy to spot any more!

Advertisement