We’ve coded the gameplay, audio, and UI for our jMonkeyEngine-powered Geometry Wars-inspired game, and now we can turn to some neat graphics effects and polish. In this part specifically, we’ll focus on particle effects (including some very colorful explosions).
Overview
Here’s what we’re working towards across the whole series:
…and here’s a video that shows off the particle effects we’re adding in this part:
There will be different types of particles as well as different emitters:
- When enemies get hit, they will die in a colorful explosion.
- When the player dies, his ship explodes in a huge golden explosion.
- The engine of the player emits a simple particle fire effect.
- Bullets that hit the border of the screen explode.
- Black holes constantly emit purple particles (so that they look cooler).
- When a black hole loses hit points, it emits a colorful particle blast.
Besides the last particle type, all particles are affected by gravity and get sucked into black holes. So, when a black hole sucks in many particles at a time, it begins glowing because of all the particles– which looks pretty cool.
Another effect we’ll add is to make our particles get bigger and therefore brighter the faster they are. This will mean that a explosion looks very bright and shiny at first, but quickly loses its brightness once the particles slow down.
In order to achieve our goals, we’ll have to add two new classes:
ParticleManager
: This manager class will take care of the attributes for each kind of explosion.ParticleControl
: I think you can already guess that this class, once again, controls the behavior of our particles.
Let’s start with the most noticeable effect: exploding enemies.
Enemy Explosions
The first class we need to implement is the ParticleManager
class. Since it’s responsible for spawning particles, it needs some variables, such as the guiNode
, the particleNode
and the standardParticle
.
We will clone this whenever we need it, but take a look at the basic code:
public class ParticleManager { private Node guiNode; private Spatial standardParticle, glowParticle; private Node particleNode; private Random rand; public ParticleManager(Node guiNode, Spatial standardParticle, Spatial glowParticle) { this.guiNode = guiNode; this.standardParticle = standardParticle; this.glowParticle = glowParticle; particleNode = new Node("particles"); guiNode.attachChild(particleNode); rand = new Random(); } }
Integrating the manager in MonkeyBlasterMain
is no big deal. We just declare it in the beginning and call the constructor in simpleInitApp()
:
particleManager = new ParticleManager(guiNode, getSpatial("Laser"), getSpatial("Glow"));
In order to actually make an enemy explode, we need to have the right method to do this in the ParticleManager
:
public void enemyExplosion(Vector3f position) { // init colors float hue1 = rand.nextFloat()*6; float hue2 = (rand.nextFloat()*2) % 6f; ColorRGBA color1 = hsvToColor(hue1, 0.5f, 1f); ColorRGBA color2 = hsvToColor(hue2, 0.5f, 1f); // create 120 particles for (int i=0; i<120; i++) { Vector3f velocity = getRandomVelocity(250); Spatial particle = standardParticle.clone(); particle.setLocalTranslation(position); ColorRGBA color = new ColorRGBA(); color.interpolate(color1, color2, rand.nextFloat()*0.5f); particle.addControl(new ParticleControl(velocity,true,3100,color)); particleNode.attachChild(particle); } }
This method is short, but it does a lot, so we’ll go through it step by step.
Coloring the Particles
In order to make our particles more interesting, we’ll assign random colors to them.
One method of producing random colors is to choose the red, blue and green components randomly, but this will produce a lot of dull colors and we’d like our particles to have a “neon light” appearance.
We can get more control over our colors by specifying them in the HSV (hue, saturation, and value) color space. We’d like to pick colors with a random hue but a fixed saturation and value, to make them all look bright and shiny, so we need a helper function that can produce a color from HSV values.
public ColorRGBA hsvToColor(float h, float s, float v) { if (h == 0 && s == 0) { return new ColorRGBA(v, v, v,1); } float c = s * v; float x = c * (1 - Math.abs(h % 2 - 1)); float m = v - c; if (h < 1) {return new ColorRGBA(c + m, x + m, m, 1); } else if (h < 2) {return new ColorRGBA(x + m, c + m, m, 1); } else if (h < 3) {return new ColorRGBA(m, c + m, x + m, 1); } else if (h < 4) {return new ColorRGBA(m, x + m, c + m, 1); } else if (h < 5) {return new ColorRGBA(x + m, m, c + m, 1); } else {return new ColorRGBA(c + m, m, x + m, 1);} }
Tip: Don’t worry too much about how this function works; just understand that it can generate an RGBA color from HSV value. The method is beyond the scope and focus of this tutorial.
Why Do We Need Two Colors?
Now back to our explosion method. Take a look at the highlighted lines:
public void enemyExplosion(Vector3f position) { // init colors float hue1 = rand.nextFloat()*6; float hue2 = (rand.nextFloat()*2) % 6f; ColorRGBA color1 = hsvToColor(hue1, 0.5f, 1f); ColorRGBA color2 = hsvToColor(hue2, 0.5f, 1f); // create 120 particles for (int i=0; i<120; i++) { Vector3f velocity = getRandomVelocity(250); Spatial particle = standardParticle.clone(); particle.setLocalTranslation(position); ColorRGBA color = new ColorRGBA(); color.interpolate(color1, color2, rand.nextFloat()*0.5f); particle.addControl(new ParticleControl(velocity,true,3100,color)); particleNode.attachChild(particle); } }
In order to make the explosion more colorful, we calculate two random colors and interpolate the final particle color randomly for each particle.
Making the Particles Move
The next thing we do is calculate the velocity for each particle. We handle this in an extra method because we want the direction to be random, but not the speed:
private Vector3f getRandomVelocity(float max) { // generate Vector3f with random direction Vector3f velocity = new Vector3f( rand.nextFloat()-0.5f, rand.nextFloat()-0.5f, 0).normalizeLocal(); // apply semi-random particle speed float random = rand.nextFloat()*5+1; float particleSpeed = max * (1f - 0.6f/random); velocity.multLocal(particleSpeed); return velocity; }
First, we generate a random velocity vector and normalize it. Next, we calculate a random speed in the range between 40% and 90% of max
.
Now back to the enemyExplosion()
method. Here is the part we have not discussed yet:
Spatial particle = standardParticle.clone(); particle.setLocalTranslation(position); ColorRGBA color = new ColorRGBA(); color.interpolate(color1, color2, rand.nextFloat()*0.5f); particle.addControl(new ParticleControl(velocity, 3100, color)); particleNode.attachChild(particle);
We clone the standardParticle
and set its translation to the origin of the explosion. After that, we interpolate the particle color between the two random ones (as mentioned above). As you can see, we also add a ParticleControl
that will control the behavior of the particle. Finally, we need to add the particle to the node for it to be displayed.
Controlling the Particles
Now that our ParticleManager
is finished, we need to implement the ParticleControl
. Parts of the code will look familiar to you:
public class ParticleControl extends AbstractControl { private Vector3f velocity; private float lifespan; private long spawnTime; private ColorRGBA color; public ParticleControl(Vector3f velocity, float lifespan, ColorRGBA color) { this.velocity = velocity; this.lifespan = lifespan; this.color = color; spawnTime = System.currentTimeMillis(); } @Override protected void controlUpdate(float tpf) { // movement spatial.move(velocity.mult(tpf*3f)); velocity.multLocal(1-3f*tpf); if (Math.abs(velocity.x)+Math.abs(velocity.y) < 0.001f) { velocity = Vector3f.ZERO; } // rotation if (velocity != Vector3f.ZERO) { spatial.rotateUpTo(velocity.normalize()); spatial.rotate(0,0,FastMath.PI/2f); } // scaling and alpha float speed = velocity.length(); long difTime = System.currentTimeMillis() - spawnTime; float percentLife = 1- difTime / lifespan; float alpha = lesserValue(1.5f,lesserValue(percentLife*2,speed)); alpha *= alpha; setAlpha(alpha); spatial.setLocalScale(0.3f+lesserValue(lesserValue(1.5f,0.02f*speed+0.1f),alpha)); spatial.scale(0.65f); // is particle expired? if (difTime > lifespan) { spatial.removeFromParent(); } } @Override protected void controlRender(RenderManager rm, ViewPort vp) {} private float lesserValue(float a, float b) { return a < b ? a : b; } private void setAlpha(float alpha) { color.set(color.r,color.g,color.b,alpha); Node spatialNode = (Node) spatial; Picture pic = (Picture) spatialNode.getChild(spatialNode.getName()); pic.getMaterial().setColor("Color",color); } }
At the top of the class, we declare and initialize a few variables; their names should be self-explanatory by now. If you take a look at controlUpdate()
you will find familiar code: We move the particle by its velocity, slow it down a bit and rotate it in the direction of the velocity.
If the particle is very slow we set its velocity to Vector3f.ZERO
. It’s much faster to do calculations with zero than a very small number, and the difference is not visible anyway.
In order to make an explosion really go boom, we will make the particle bigger when it moves fast, which is usually right after the explosion. In the same manner, we make the particle smaller and even transparent when it’s moving very slowly or reaches the end of its lifespan. To make it more transparent we call a helper method, setAlpha(float alpha)
.
Tip: If you don’t know how to get children spatials and set their material, you can either just copy-paste the method or have a look at SeekerControl
or WandererControl
from the second chapter; it’s explained there.
Now that we’ve finished the ParticleControl
, you can start the game and see… nothing.
Do you know what we forgot?
Putting it Together
When an enemy dies, we need to call enemyExplosion()
in the ParticleManager
, otherwise nothing will happen! Take a look at MonkeyBlasterMain
and look for the method handleCollisions()
, this is where enemies die. Now just insert the call in the right line:
// ... } else if (enemyNode.getChild(i).getName().equals("Wanderer")) { hud.addPoints(1); } particleManager.enemyExplosion(enemyNode.getChild(i).getLocalTranslation()); enemyNode.detachChildAt(i); bulletNode.detachChildAt(j); sound.explosion(); break; // ...
And you must not forget that there is a second way that enemies can die: when they get sucked into black holes. Just insert the (almost) same line a few lines further down when we check for collisions with the black hole:
if (checkCollision(enemyNode.getChild(j),blackHole)) { particleManager.enemyExplosion(enemyNode.getChild(j).getLocalTranslation()); enemyNode.detachChildAt(j); }
Now you can finally start the game and play a little. Those particles really add to the atmosphere, don’t you think? But let’s not stop at one effect; there are many more to come…
Bullet Explosions
When a bullet hits the border of the screen, we will make it explode as well.
Take a look at BulletControl
. There is already code that checks whether the bullet is outside the borders of the screen, so let’s trigger the explosion there. In order to do that we need to declare the ParticleManager
in BulletControl
and pass it in the constructor:
public BulletControl(Vector3f direction, int screenWidth, int screenHeight, ParticleManager particleManager) { this.particleManager = particleManager;
Don’t forget that you need to pass the particleManager
in MonkeyBlasterMain
.
We’ll insert the call here:
if (loc.x screenWidth || loc.y > screenHeight) { particleManager.bulletExplosion(loc); spatial.removeFromParent(); }
The bulletExplosion(Vector3f position)
method is very similar to the enemyExplosion(Vector3f position)
method. The only differences are that we won’t make the particles quite as fast, and that we use a fixed color (a bright blue). Also, we decrease the lifespan of the particles.
public void bulletExplosion(Vector3f position) { for (int i=0; i<30; i++) { Vector3f velocity = getRandomVelocity(175); Spatial particle = standardParticle.clone(); particle.setLocalTranslation(position); ColorRGBA color = new ColorRGBA(0.676f,0.844f,0.898f,1); particle.addControl(new ParticleControl(velocity, 1000, color)); particleNode.attachChild(particle); } }
Since we have all the necessary code in place, it’s easy to add new explosions, as you can see. Before we add another explosion for the players death, we’ll add a new functionality to the ParticleControl
.
Repelling Particles From the Screen Borders
When a bullet hits the border of the screen, about half of the particles are useless. Those particles never actually appear on the screen because they fly away from it. Let’s change that.
We will now turn the velocity of every particle that leaves the screen around, so that they get ‘repelled’ by the boundaries.
Vector3f loc = spatial.getLocalTranslation(); if (loc.x < 0) { velocity.x = Math.abs(velocity.x); } else if (loc.x > screenWidth) { velocity.x = -Math.abs(velocity.x); } if (loc.z < 0) { velocity.y = Math.abs(velocity.y); } else if (loc.y > screenHeight) { velocity.y = -Math.abs(velocity.y); }
We don’t invert the whole vector, only the x
or y
variable (depending on the border that was hit). This results in a proper repelling effect, like a mirror reflecting light.
Tip: You must not forget to pass screenWidth
and screenHeight
from MonkeyBlasterMain
to ParticleManager
and from there to every ParticleControl
. If you don’t care about clean code that much you could make two static variables in MonkeyBlasterMain
and work with them.
Start the game, and you will notice that bullet explosions look much brighter now. Particles from enemy explosions get repelled as well.
Player Explosion
When the player dies, we want a really big explosion that covers the whole screen. We call the method, once again, in killPlayer()
in MonkeyBlasterMain
.
particleManager.playerExplosion(player.getLocalTranslation());
The code for playerExplosion
is pretty much the same as before. However, this time we use two colors, white and yellow, and interpolate between them. We set the velocity to 1000
and the lifespan to 2800
milliseconds.
public void playerExplosion(Vector3f position) { ColorRGBA color1 = ColorRGBA.White; ColorRGBA color2 = ColorRGBA.Yellow; for (int i=0; i<1200; i++) { Vector3f velocity = getRandomVelocity(1000); Spatial particle = standardParticle.clone(); particle.setLocalTranslation(position); ColorRGBA color = new ColorRGBA(); color.interpolate(color1, color2, rand.nextFloat()); particle.addControl(new ParticleControl(velocity, 2800, color, screenWidth, screenHeight)); particleNode.attachChild(particle); } }
Sucking Particles Into Black Holes
Now that we have quite a few particle effects, let’s add gravity to them. Whenever they come close enough to a black hole, they should be sucked in—but this is not true for every particle. Later on, we’ll want to have a type of particle that does get sucked in and a type that does not. Therefore, we need to add an attribute to our particles:
particle.setUserData("affectedByGravity", true);
All particle types that we’ve created up to now should be sucked in by black holes, so you can add this line to every method in which we spawn particles.
Now to the handling of the gravity. Go to handleGravity()
in MonkeyBlasterMain
—this is where we implemented the gravity in the third part of the series.
This time, we won’t check whether a particle is within reach of the black hole, we’ll simply apply the gravity to all of them. If a specific particle is far away, the gravitational effect will not be very strong anyway.
We check whether the particle is affected by gravity and, if it is, we apply it:
// check Particles for (int j=0; j<particleNode.getQuantity(); j++) { if (particleNode.getChild(j).getUserData("affectedByGravity")) { applyGravity(blackHoleNode.getChild(i), particleNode.getChild(j), tpf); } } }
Now, we’ll need to extend applyGravity()
as well:
// ... } else if (target.getName().equals("Laser") || target.getName().equals("Glow")) { target.getControl(ParticleControl.class).applyGravity(gravity.mult(15000), distance); }
We need to check the target’s name for both Laser and Glow, because those are two different types of particles that will have the same behavior.
Another thing to notice is that we don’t only pass on the modified gravity vector, but also the distance to the black hole. This is important in the calculation of the force in applyGravity()
in ParticleControl
:
Vector3f additionalVelocity = gravity.mult(1000f / (distance*distance + 10000f)); velocity.addLocal(additionalVelocity); if (distance < 400) { additionalVelocity = new Vector3f(gravity.y, -gravity.x, 0).mult(3f / (distance + 100)); velocity.addLocal(additionalVelocity); }
Here, gravity
is the unit vector pointing towards the black hole. The attractive force is a modified version of the inverse square function.
The first modification is that the denominator is (distance * distance) + 10000
—that is, it contains a distance-squared term. This causes the attractive force to approach a maximum value instead of tending towards infinity as the distance becomes very small.
When the distance becomes greater than 100 pixels, (distance * distance)
quickly becomes much greater than 10,000. Therefore, adding 10,000 to (distance * distance)
has a very small effect, and the function approximates a normal inverse square function.
However, when the distance is much smaller than 100 pixels, the distance has a small effect on the value of the denominator, and the equation becomes approximately equal to:
vel += n;
The second modification we’ve made is adding a sideways component to the velocity when the particles get close enough to the black hole. This serves two purposes: first, it makes the particles spiral clockwise in towards the black hole; second, when the particles get close enough, they will reach equilibrium and form a glowing circle around the black hole.
Tip: To rotate a vector,v
, 90° clockwise, take (v.y, -v.x)
. Similarly, to rotate 90° counter-clockwise, take (-v.y, v.x)
.This particle effect seems pretty when you start the game and look at it, and this is especially true when there are many explosions and particles around. But when there are no explosions, black holes look kind of dull. We’ll change that soon.
Spraying Particles Out of Black Holes
In order to make black holes continuously produce particles, we need to take a look at the controlUpdate(float tpf)
method in BlackHoleControl
. There is an if
statement that checks wether the black hole is active; if it is, we’ll make it execute this code:
long sprayDif = System.currentTimeMillis() - lastSprayTime; if ((System.currentTimeMillis() / 250) % 2 == 0 && sprayDif > 20) { lastSprayTime = System.currentTimeMillis(); Vector3f sprayVel = MonkeyBlasterMain.getVectorFromAngle(sprayAngle).mult(rand.nextFloat()*3 +6); Vector3f randVec = MonkeyBlasterMain.getVectorFromAngle(rand.nextFloat()*FastMath.PI*2); randVec.multLocal(4+rand.nextFloat()*4); Vector3f position = spatial.getLocalTranslation().add(sprayVel.mult(2f)).addLocal(randVec); particleManager.sprayParticle(position,sprayVel.mult(30f)); } sprayAngle -= FastMath.PI*tpf/10f;
We have a couple new variables here. You need to declare and initialize the long lastSprayTime
, the float sprayAngle
and the Random rand
. Also, you need to declare the particleManager
and pass it down from the main class so we can actually spray the particles.
The method will cause the black holes to spray spurts of purple particles that will form a cool glowing ring that orbits around the black hole
The actual sprayParticle()
method is nothing special. We create a particle, apply a purple color, add a control and so on:
public void sprayParticle(Vector3f position, Vector3f sprayVel) { Spatial particle = standardParticle.clone(); particle.setLocalTranslation(position); ColorRGBA color = new ColorRGBA(0.8f,0.4f,0.8f,1f); particle.addControl(new ParticleControl(sprayVel, 3500, color, screenWidth, screenHeight)); particle.setUserData("affectedByGravity", true); ((Node) guiNode.getChild("particles")).attachChild(particle); }
Start up the game and see how it looks.
Tip: If you want to change the circling behavior of the particles, feel free to play around with the values in applyGravity()
in ParticleControl
.
This improves the general look of the black holes, but it’s not good enough yet! There is one other effect we can add to them…
Black Hole Explosions
Now, we won’t make the black holes explode when they die. We will, instead, trigger a particle explosion every time a black hole gets hit.
Add the following method to ParticleManager
:
public void blackHoleExplosion(Vector3f position) { float hue = ((System.currentTimeMillis()-spawnTime)*0.003f) % 6f; int numParticles = 150; ColorRGBA color = hsvToColor(hue, 0.25f, 1); float startOffset = rand.nextFloat() * FastMath.PI * 2 / numParticles; for (int i=0; i<numParticles; i++) { float alpha = FastMath.PI * 2 * i / numParticles + startOffset; Vector3f velocity = MonkeyBlasterMain.getVectorFromAngle(alpha).multLocal(rand.nextFloat()*200 + 300); Vector3f pos = position.add(velocity.mult(0.1f)); Spatial particle = standardParticle.clone(); particle.setLocalTranslation(pos); particle.addControl(new ParticleControl(velocity, 1000, color, screenWidth, screenHeight)); particle.setUserData("affectedByGravity", false); ((Node) guiNode.getChild("particles")).attachChild(particle); } }
This works mostly the same way as the other particle explosions. One difference is that we pick the hue of the color based on the total elapsed game time. If you shoot the black hole multiple times in rapid succession, you will see the hue of the explosions gradually rotate. This looks less messy than using random colors while still allowing variation.
Ship Exhaust Fire
As dictated by the laws of geometric-neon physics, the player’s ship propels itself by jetting a stream of fiery particles out of its exhaust pipe. With our particle engine in place, this effect is easy to make and adds visual flair to the ship’s movement.
As the ship moves, we create three streams of particles: a central stream that fires straight out the back of the ship, and two side streams whose angles swivel back and forth relative to the ship. The two side streams swivel in opposite directions to make a criss-crossing pattern, and have a redder color, while the center stream has a hotter, yellow-white color.
To make the fire glow more brightly than it would from bloom alone, we will have the ship emit additional particles that look like this:
These particles will be tinted and blended with the regular particles. The code for the entire effect is shown below:
public void makeExhaustFire(Vector3f position, float rotation) { ColorRGBA midColor = new ColorRGBA(1f,0.73f,0.12f,0.7f); ColorRGBA sideColor = new ColorRGBA(0.78f,0.15f,0.04f,0.7f); Vector3f direction = MonkeyBlasterMain.getVectorFromAngle(rotation); float t = (System.currentTimeMillis()-spawnTime)/1000f; Vector3f baseVel = direction.mult(-45f); Vector3f perpVel = new Vector3f(baseVel.y,-baseVel.x,0).multLocal(2f * FastMath.sin(t * 10f)); Vector3f pos = position.add(MonkeyBlasterMain.getVectorFromAngle(rotation).multLocal(-25f)); //middle stream Vector3f randVec = MonkeyBlasterMain.getVectorFromAngle(new Random().nextFloat() * FastMath.PI * 2); Vector3f velMid = baseVel.add(randVec.mult(7.5f)); Spatial particleMid = standardParticle.clone(); particleMid.setLocalTranslation(pos); particleMid.addControl(new ParticleControl(velMid, 800, midColor, screenWidth, screenHeight)); particleMid.setUserData("affectedByGravity", true); ((Node) guiNode.getChild("particles")).attachChild(particleMid); Spatial particleMidGlow = glowParticle.clone(); particleMidGlow.setLocalTranslation(pos); particleMidGlow.addControl(new ParticleControl(velMid, 800, midColor, screenWidth, screenHeight)); particleMidGlow.setUserData("affectedByGravity", true); ((Node) guiNode.getChild("particles")).attachChild(particleMidGlow); //side streams Vector3f randVec1 = MonkeyBlasterMain.getVectorFromAngle(new Random().nextFloat() * FastMath.PI * 2); Vector3f randVec2 = MonkeyBlasterMain.getVectorFromAngle(new Random().nextFloat() * FastMath.PI * 2); Vector3f velSide1 = baseVel.add(randVec1.mult(2.4f)).addLocal(perpVel); Vector3f velSide2 = baseVel.add(randVec2.mult(2.4f)).subtractLocal(perpVel); Spatial particleSide1 = standardParticle.clone(); particleSide1.setLocalTranslation(pos); particleSide1.addControl(new ParticleControl(velSide1, 800, sideColor, screenWidth, screenHeight)); particleSide1.setUserData("affectedByGravity", true); ((Node) guiNode.getChild("particles")).attachChild(particleSide1); Spatial particleSide2 = standardParticle.clone(); particleSide2.setLocalTranslation(pos); particleSide2.addControl(new ParticleControl(velSide2, 800, sideColor,screenWidth, screenHeight)); particleSide2.setUserData("affectedByGravity", true); ((Node) guiNode.getChild("particles")).attachChild(particleSide2); Spatial particleSide1Glow = glowParticle.clone(); particleSide1Glow.setLocalTranslation(pos); particleSide1Glow.addControl(new ParticleControl(velSide1, 800, sideColor, screenWidth, screenHeight)); particleSide1Glow.setUserData("affectedByGravity", true); ((Node) guiNode.getChild("particles")).attachChild(particleSide1Glow); Spatial particleSide2Glow = glowParticle.clone(); particleSide2Glow.setLocalTranslation(pos); particleSide2Glow.addControl(new ParticleControl(velSide2, 800, sideColor, screenWidth, screenHeight)); particleSide2Glow.setUserData("affectedByGravity", true); ((Node) guiNode.getChild("particles")).attachChild(particleSide2Glow); }
There’s nothing sneaky going on in this code. We use a sine function to produce the swivelling effect in the side streams by varying their sideways velocity over time. For each stream, we create two overlapping particles per frame: one standard particle and a glow particle behind it.
Insert this bit of code in PlayerControl
, at the end of controlUpdate(float tpf)
:
if (up || down || left || right) { particleManager.makeExhaustFire(spatial.getLocalTranslation(),rotation); }
Of course you must not forget to pass the particleManager
from MonkeyBlasterMain
.
Conclusion
With all these particle effects, Shape Blaster is starting to look pretty cool. In the final part of this series, we will add one more awesome effect: the warping background grid