In the first part of this series on building a Geometry Wars-inspired game in jMonkeyEngine, we implemented the player's ship and let it move and shoot. This time, we'll add the enemies and sound effects.
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:
We'll need some new classes in order to implement the new features:
SeekerControl
: This is a behavior class for the seeker enemy.WandererControl
: This is also a behavior class, this time for the wanderer enemy.Sound
: We'll manage the loading and playing of sound effects and music with this.
As you might have guessed, we'll add two types of enemies. The first one is called a seeker; it will actively chase the player until it dies. The other one, the wanderer, just roams around the screen in a random pattern.
Adding Enemies
We'll spawn the enemies at random positions on the screen. In order to give the player some time to react, the enemy won't be active immediately, but rather will fade in slowly. After it has faded in completely, it will begin moving through the world. When it collides with the player, the player dies; when it collides with a bullet, it dies itself.
Spawning Enemies
First of all, we need to create some new variables in the MonkeyBlasterMain
class:
private long enemySpawnCooldown; private float enemySpawnChance = 80; private Node enemyNode;
We'll get to use the first two soon enough. Before that, we need to initialize the enemyNode
in simpleInitApp()
:
// set up the enemyNode enemyNode = new Node("enemies"); guiNode.attachChild(enemyNode);
Okay, now on to the real spawning code: we'll override simpleUpdate(float tpf)
. This method gets called by the engine over and over again, and simply keeps calling the enemy spawning function as long as the player is alive. (We already set the userdata alive
to true
in the last tutorial.)
@Override public void simpleUpdate(float tpf) { if ((Boolean) player.getUserData("alive")) { spawnEnemies(); } }
And this is how we actually spawn the enemies:
private void spawnEnemies() { if (System.currentTimeMillis() - enemySpawnCooldown >= 17) { enemySpawnCooldown = System.currentTimeMillis(); if (enemyNode.getQuantity() < 50) { if (new Random().nextInt((int) enemySpawnChance) == 0) { createSeeker(); } if (new Random().nextInt((int) enemySpawnChance) == 0) { createWanderer(); } } //increase Spawn Time if (enemySpawnChance >= 1.1f) { enemySpawnChance -= 0.005f; } } }
Don't get confused by the enemySpawnCooldown
variable. It's not there to make enemies spawn at a decent frequency—17ms would be much too short of an interval.
enemySpawnCooldown
is actually there to ensure that the quantity of new enemies is the same on every machine. On faster computers, simpleUpdate(float tpf)
gets called much more often than on slower ones. With this variable we check about every 17ms if we should spawn new enemies.
But do we want to spawn them every 17ms? We actually want them to spawn in random intervals, so we introduce an if
statement:
if (new Random().nextInt((int) enemySpawnChance) == 0) {
The smaller the value of enemySpawnChance
, the more probable it is that a new enemy will spawn in this 17ms interval, and so the more enemies the player will need to deal with. That's why we subtract a little bit of enemySpawnChance
every tick: it means that the game will get more difficult over time.
Creating seekers and wanderers is similar to creating any other object:
private void createSeeker() { Spatial seeker = getSpatial("Seeker"); seeker.setLocalTranslation(getSpawnPosition()); seeker.addControl(new SeekerControl(player)); seeker.setUserData("active",false); enemyNode.attachChild(seeker); } private void createWanderer() { Spatial wanderer = getSpatial("Wanderer"); wanderer.setLocalTranslation(getSpawnPosition()); wanderer.addControl(new WandererControl()); wanderer.setUserData("active",false); enemyNode.attachChild(wanderer); }
We create the spatial, we move it, we add a custom control, we set it non-active, and we attach it to our enemyNode
. What? Why non-active? That's because we don't want the enemy to start chasing the player as soon as it spawns; we want to give the player some time to react.
Before we get into the controls, we need to implement the method getSpawnPosition()
. The enemy should spawn randomly, but not right next to the player:
private Vector3f getSpawnPosition() { Vector3f pos; do { pos = new Vector3f(new Random().nextInt(settings.getWidth()), new Random().nextInt(settings.getHeight()),0); } while (pos.distanceSquared(player.getLocalTranslation()) < 8000); return pos; }
We calculate a new random position pos
. If it's too close to the player, we calculate a new position, and repeat until it's a decent distance away.
Now we just need to make the enemies set themselves active and start moving. We'll do that in their controls.
Controlling Enemy Behavior
We'll deal with the SeekerControl
first:
public class SeekerControl extends AbstractControl { private Spatial player; private Vector3f velocity; private long spawnTime; public SeekerControl(Spatial player) { this.player = player; velocity = new Vector3f(0,0,0); spawnTime = System.currentTimeMillis(); } @Override protected void controlUpdate(float tpf) { if ((Boolean) spatial.getUserData("active")) { //translate the seeker Vector3f playerDirection = player.getLocalTranslation().subtract(spatial.getLocalTranslation()); playerDirection.normalizeLocal(); playerDirection.multLocal(1000f); velocity.addLocal(playerDirection); velocity.multLocal(0.8f); spatial.move(velocity.mult(tpf*0.1f)); // rotate the seeker if (velocity != Vector3f.ZERO) { spatial.rotateUpTo(velocity.normalize()); spatial.rotate(0,0,FastMath.PI/2f); } } 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("Seeker"); pic.getMaterial().setColor("Color",color); } } @Override protected void controlRender(RenderManager rm, ViewPort vp) {} }
Let's focus on controlUpdate(float tpf)
:
First, we need to check whether the enemy is active. If it's not, we need to slowly fade it in.
We then check the time that has elapsed since we spawned the enemy and, if it's long enough, we set it active.
Regardless of whether we've just set it active, we need to adjust its color. The local variable spatial
contains the spatial that the control has been attached to, but you may remember that we did not attach the control to the actual picture—the picture is a child of the node we attached the control to. (If you don't know what I'm talking about, take a look at the method getSpatial(String name)
we implemented last tutorial.)
So; we get the picture as a child of spatial
, get its material and set its color to the appropiate value. Nothing special once you're used to the spatials, materials and nodes.
1
in our code). Don't we want a yellow and a red enemy?It's because the material mixes the material color with the texture colors, so if we want to display the texture of the enemy as it is, we need to mix it with white.
Now we need to take a look at what we do when the enemy is active. This control is named SeekerControl
for a reason: we want enemies with this control attached to follow the player.
In order to achieve that, we calculate the direction from the seeker to the player and add this value to the velocity. After that, we decrease the velocity by 80% so that it can't grow infinitely, and move the seeker accordingly.
The rotation is nothing special: if the seeker is not standing still, we rotate it in the direction of the player. We then rotate it a little more because the seeker in Seeker.png
is not pointing upwards, but to the right.
rotateUpTo(Vector3f direction)
method of Spatial
rotates a spatial so that its y-axis points in the given direction.So that was the first enemy. The code of the second enemy, the wanderer, is not much different:
public class WandererControl extends AbstractControl { private int screenWidth, screenHeight; private Vector3f velocity; private float directionAngle; private long spawnTime; public WandererControl(int screenWidth, int screenHeight) { this.screenWidth = screenWidth; this.screenHeight = screenHeight; velocity = new Vector3f(); directionAngle = new Random().nextFloat() * FastMath.PI * 2f; spawnTime = System.currentTimeMillis(); } @Override protected void controlUpdate(float tpf) { if ((Boolean) spatial.getUserData("active")) { // translate the wanderer // change the directionAngle a bit directionAngle += (new Random().nextFloat() * 20f - 10f) * tpf; System.out.println(directionAngle); Vector3f directionVector = MonkeyBlasterMain.getVectorFromAngle(directionAngle); directionVector.multLocal(1000f); velocity.addLocal(directionVector); // decrease the velocity a bit and move the wanderer velocity.multLocal(0.8f); spatial.move(velocity.mult(tpf*0.1f)); // make the wanderer bounce off the screen borders Vector3f loc = spatial.getLocalTranslation(); if (loc.x screenWidth || loc.y > screenHeight) { Vector3f newDirectionVector = new Vector3f(screenWidth/2, screenHeight/2,0).subtract(loc); directionAngle = MonkeyBlasterMain.getAngleFromVector(newDirectionVector); } // rotate the wanderer spatial.rotate(0,0,tpf*2); } 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("Wanderer"); pic.getMaterial().setColor("Color",color); } } @Override protected void controlRender(RenderManager rm, ViewPort vp) {} }
The easy stuff first: fading the enemy in is the same as in the seeker control. In the constructor, we choose a random direction for the wanderer, in which it will fly once activated.
EnemyControl
It would handle everything that all enemies had in common: moving the enemy, fading it in, setting it active...Now to the major differences:
When the enemy is active, we first change its direction a bit, so that the wanderer doesn't move in a straight line all the time. We do this by changing our directionAngle
a bit and adding the directionVector
to the velocity
. We then apply the velocity just like we do in the SeekerControl
.
We need to check whether the wanderer is outside of the screen borders and, if so, we change the directionAngle
to a more appropiate direction so that it gets applied in the next update.
Finally, we rotate the wanderer a bit. This is just because a spinning enemy looks cooler.
Now that we've finished implementing both of the enemies, you can start the game and play a bit. It gives you a little glance at how the game will play, even though you can't kill the enemies and they can't kill you either. Let's add that next.
Collision Detection
In order to make enemies kill the player, we need to know whether they are colliding. For this, we'll add a new method, handleCollisions
, called in simpleUpdate(float tpf)
:
@Override public void simpleUpdate(float tpf) { if ((Boolean) player.getUserData("alive")) { spawnEnemies(); handleCollisions(); } }
And now the actual method:
private void handleCollisions() { // should the player die? for (int i=0; i<enemyNode.getQuantity(); i++) { if ((Boolean) enemyNode.getChild(i).getUserData("active")) { if (checkCollision(player,enemyNode.getChild(i))) { killPlayer(); } } } }
We iterate through all the enemies by gettings the quantity of the children of the node and then getting each one of them. Furthermore we only need to check wether the enemy kills the player when the enemy is actually active. If it isn't, we don't need to care about it. So if he is active, we check wether the player and the enemy collide. We do that in another method, checkCollisoin(Spatial a, Spatial b)
:
private boolean checkCollision(Spatial a, Spatial b) { float distance = a.getLocalTranslation().distance(b.getLocalTranslation()); float maxDistance = (Float)a.getUserData("radius") + (Float)b.getUserData("radius"); return distance <= maxDistance; }
The concept is pretty simple: first, we calculate the distance between the two spatials. Next, we need to know how close the two spatials need to be in order to be considered as having collided, so we get the radius of each spatial and add them. (We set the user data "radius" in getSpatial(String name)
in the previous tutorial.) So, if the actual distance is shorter than or equal to this maximum distance, the method returns true
, which means they collided.
What now? We need to kill the player. Let's create another method:
private void killPlayer() { player.removeFromParent(); player.getControl(PlayerControl.class).reset(); player.setUserData("alive", false); player.setUserData("dieTime", System.currentTimeMillis()); enemyNode.detachAllChildren(); } }
First, we detach the player from its parent node, which automatically removes it from the scene. Next, we need to reset the movement in PlayerControl
—otherwise, the player might still move when it spawns again.
We then set the userdata alive
to false
and create a new userdata dieTime
. (We'll need that to respawn the player when it's dead.)
Finally, we detach all enemies, as the player would have a hard time fighting the already existing enemies off right when it spawns.
We already mentioned respawning, so let's handle that next. We will, once again, modify the simpleUpdate(float tpf)
method:
@Override public void simpleUpdate(float tpf) { if ((Boolean) player.getUserData("alive")) { spawnEnemies(); handleCollisions(); } else if (System.currentTimeMillis() - (Long) player.getUserData("dieTime") > 4000f && !gameOver) { // spawn player player.setLocalTranslation(500,500,0); guiNode.attachChild(player); player.setUserData("alive",true); } }
So, if the player is not alive and has been dead long enough, we set its position to the middle of the screen, add it to the scene, and finally set its userdata alive
to true
again!
Now may be a good time to start the game and test our new features. You'll have a hard time lasting longer than twenty seconds, though, because your gun is worthless, so let's do something about that.
In order to make bullets kill enemies, we'll add some code to the handleCollisions()
method:
//should an enemy die? int i=0; while (i < enemyNode.getQuantity()) { int j=0; while (j < bulletNode.getQuantity()) { if (checkCollision(enemyNode.getChild(i),bulletNode.getChild(j))) { enemyNode.detachChildAt(i); bulletNode.detachChildAt(j); break; } j++; } i++; }
The procedure for killing enemies is pretty much the same as for killing the player; we iterate through all enemies and all bullets, check whether they collide and, if they do, we detach both of them.
Now run the game and see how far you get!
Now we are finished with the main gameplay. We're still going to implement black holes and display the score and lives of the player, and to make the game more fun and exciting we'll add sound effects and better graphics. The latter will be achieved through the bloom post processing filter, some particle effects and a cool background effect.
Before we consider this part of the series finished, we'll add some audio and the bloom effect.
Playing Sounds and Music
In order to some audio to our game we'll create a new class, simply called Sound
:
public class Sound { private AudioNode music; private AudioNode[] shots; private AudioNode[] explosions; private AudioNode[] spawns; private AssetManager assetManager; public Sound(AssetManager assetManager) { this.assetManager = assetManager; shots = new AudioNode[4]; explosions = new AudioNode[8]; spawns = new AudioNode[8]; loadSounds(); } private void loadSounds() { music = new AudioNode(assetManager,"Sounds/Music.ogg"); music.setPositional(false); music.setReverbEnabled(false); music.setLooping(true); for (int i=0; i<shots.length; i++) { shots[i] = new AudioNode(assetManager,"Sounds/shoot-0"+(i+1)+".wav"); shots[i].setPositional(false); shots[i].setReverbEnabled(false); shots[i].setLooping(false); } for (int i=0; i<explosions.length; i++) { explosions[i] = new AudioNode(assetManager,"Sounds/explosion-0"+(i+1)+".wav"); explosions[i].setPositional(false); explosions[i].setReverbEnabled(false); explosions[i].setLooping(false); } for (int i=0; i<spawns.length; i++) { spawns[i] = new AudioNode(assetManager,"Sounds/spawn-0"+(i+1)+".wav"); spawns[i].setPositional(false); spawns[i].setReverbEnabled(false); spawns[i].setLooping(false); } } public void startMusic() { music.play(); } public void shoot() { shots[new Random().nextInt(shots.length)].playInstance(); } public void explosion() { explosions[new Random().nextInt(explosions.length)].playInstance(); } public void spawn() { spawns[new Random().nextInt(spawns.length)].playInstance(); } }
Here, we start by setting up the necessary AudioNode
variables and initialize the arrays.
Next, we load the sounds, and for each sound we do pretty much the same thing. We create a new AudioNode
, with the help of the assetManager
. Then, we set it not positional and disable reverb. (We don't need the sound to be positional because we don't have stereo output in our 2D game, though you could implement it if you liked.) Disabling the reverb makes the sound be played just like it is in the actual audio file; if we enabled it, we could make jME let the audio sound like we'd be in a cave or dungeon, for example. After that, we set the looping to true
for the music and to false
for any other sound.
Playing the sounds is pretty simple: we just call soundX.play()
.
play()
on some sound, it just plays the sound. But sometimes we want to play the same sound twice or even more times simultaneously. That's what playInstance()
is there for: it creates a new instance for every sound so that we can play the same sound multiple times at the same time.I'll leave the rest of the work up to you: you need to call startMusic
, shoot()
, explosion()
(for dying enemies), and spawn()
at the appropriate places in our main class MonkeyBlasterMain()
.
When you're finished, you'll see that the game is now much more fun; those few sound effects really add to the atmosphere. But let's polish the graphics a bit as well.
Adding the Bloom Post-Processing Filter
Enabling bloom is very simple in the jMonkeyEngine, as all of the necessary code and shaders are already implemented for you. Just go ahead and paste these lines into simpleInitApp()
:
FilterPostProcessor fpp=new FilterPostProcessor(assetManager); BloomFilter bloom=new BloomFilter(); bloom.setBloomIntensity(2f); bloom.setExposurePower(2); bloom.setExposureCutOff(0f); bloom.setBlurScale(1.5f); fpp.addFilter(bloom); guiViewPort.addProcessor(fpp); guiViewPort.setClearColor(true);
I've configured the BloomFilter
a bit; if you want to know what all these settings are there for, you should check out the jME tutorial on bloom.
Conclusion
Congratulations for finishing the second part. There are three more parts to go, so don't get distracted by playing for too long! Next time, we'll add the GUI and the black holes.