In the series so far, we've coded the gameplay, added enemies, and spiced things up with bloom and particle effects. In this final part, we will create a dynamic, warping background grid.
Overview
This video shows the grid in action:
We'll make the grid using a spring simulation: at each intersection of the grid, we'll place a small weight (a point mass), and we'll connect these weights using springs. These springs will only pull and never push, much like a rubber band. To keep the grid in position, the masses around the border of the grid will be anchored in place.
Below is a diagram of the layout.
We'll create a class called Grid
to create this effect. However, before we work on the grid itself, we need to make two helper classes: Spring
and PointMass
.
The PointMass Class
The PointMass
class represents the masses to which we will attach the springs. Springs never connect directly to other springs. Instead, they apply a force to the masses they connect, which in turn may stretch other springs.
public class PointMass { private Vector3f position; private Vector3f velocity = Vector3f.ZERO; private float inverseMass; private Vector3f acceleration = Vector3f.ZERO; private float damping = 0.98f; public PointMass(Vector3f position, float inverseMass) { this.position = position; this.inverseMass = inverseMass; } public void applyForce(Vector3f force) { acceleration.addLocal(force.mult(inverseMass)); } public void increaseDamping(float factor) { damping *= factor; } public void update(float tpf) { velocity.addLocal(acceleration.mult(1f)); position.addLocal(velocity.mult(0.6f)); acceleration = Vector3f.ZERO.clone(); if (velocity.lengthSquared() < 0.0001f) { velocity = Vector3f.ZERO.clone(); } velocity.multLocal(damping); damping = 0.98f; damping = 0.8f; position.z *= 0.9f; if (position.z < 0.01) {position.z = 0;} } public Vector3f getPosition() { return position; } public Vector3f getVelocity() { return velocity; } }
There are a few interesting points about this class. First, notice that it stores the inverse of the mass, 1 / mass
. This is often a good idea in physics simulations because physics equations tend to use the inverse of the mass more often, and because it gives us an easy way to represent infinitely heavy, immovable objects by setting the inverse mass to zero.
The class also contains a damping variable, which acts to gradually slow the mass down. This is used roughly as friction or air resistance. This helps make the grid eventually come to rest and also increases the stability of the spring simulation.
The Update()
method does the work of moving the point mass each frame. It begins by doing symplectic Euler integration, which just means we add the acceleration to the velocity and then add the updated velocity to the position. This differs from standard Euler integration in which we would update the velocity after updating the position.
After updating the velocity and position, we check if the velocity is very small and, if it is, we set it to zero. This can be important to performance due to the nature of denormalized floating-point numbers.
The IncreaseDamping()
method is used to temporarily increase the amount of damping. We will use this later for certain effects.
The Spring Class
A spring connects two point masses, and, if stretched past its natural length, applies a force pulling the masses together. Springs follow a modified version of Hooke's Law with damping:
\[f = -kx - bv\]
- \(f\) is the force produced by the spring.
- \(k\) is the spring constant, or the "stiffness" of the spring.
- \(x\) is the distance the spring is stretched beyond its natural length.
- \(b\) is the damping factor.
- \(v\) is the velocity.
The code for the Spring
class is as follows:
public class Spring { private PointMass end1; private PointMass end2; private float targetLength; private float stiffness; private float damping; public Spring(PointMass end1, PointMass end2, float stiffness, float damping, Node gridNode, boolean visible, Geometry defaultLine) { this.end1 = end1; this.end2 = end2; this.stiffness = stiffness; this.damping = damping; targetLength = end1.getPosition().distance(end2.getPosition()) * 0.95f; if (visible) { defaultLine.addControl(new LineControl(end1,end2)); gridNode.attachChild(defaultLine); } } public void update(float tpf) { Vector3f x = end1.getPosition().subtract(end2.getPosition()); float length = x.length(); if (length > targetLength) { x.normalizeLocal(); x.multLocal(length - targetLength); Vector3f dv = end2.getVelocity().subtract(end1.getVelocity()); Vector3f force = x.mult(stiffness); force.subtract(dv.mult(damping/10f)); end1.applyForce(force.negate()); end2.applyForce(force); } } }
When we create a spring, we set the natural length of the spring to be just slightly less than the distance between the two end points. This keeps the grid taut, even when at rest, and improves the appearance somewhat.
The Update()
method first checks whether the spring is stretched beyond its natural length. If it is not stretched, nothing happens. If it is, we use the modified Hooke's Law to find the force from the spring and apply it to the two connected masses.
There is another class we need to create in order to display the lines correctly. The LineControl
will take care of moving, scaling and rotating the lines:
public class LineControl extends AbstractControl { private PointMass end1, end2; public LineControl(PointMass end1, PointMass end2) { this.end1 = end1; this.end2 = end2; } @Override protected void controlUpdate(float tpf) { //movement spatial.setLocalTranslation(end1.getPosition()); //scale Vector3f dif = end2.getPosition().subtract(end1.getPosition()); spatial.setLocalScale(dif.length()); //rotation spatial.lookAt(end2.getPosition(),new Vector3f(1,0,0)); } @Override protected void controlRender(RenderManager rm, ViewPort vp) { } }
Creating the Grid
Now that we have the necessary nested classes, we're ready to create the grid. We start by creating PointMass
objects at each intersection on the grid. We also create some immovable anchor PointMass
objects to hold the grid in place. We then link the masses together with springs:
public class Grid { private Node gridNode; private Spring[] springs; private PointMass[][] points; private Geometry defaultLine; private Geometry thickLine; public Grid(Rectangle size, Vector2f spacing, Node guiNode, AssetManager assetManager) { gridNode = new Node(); guiNode.attachChild(gridNode); defaultLine = createLine(1f,assetManager); thickLine = createLine(3f,assetManager); ArrayList springList = new ArrayList(); float stiffness = 0.28f; float damping = 0.06f; int numColumns = (int)(size.width / spacing.x) + 2; int numRows = (int)(size.height / spacing.y) + 2; points = new PointMass [numColumns][numRows]; PointMass[][] fixedPoints = new PointMass[numColumns][numRows]; // create the point masses float xCoord=0, yCoord=0; for (int row = 0; row < numRows; row++) { for (int column = 0; column < numColumns; column++) { points[column][row] = new PointMass(new Vector3f(xCoord,yCoord,0),1); fixedPoints[column][row] = new PointMass(new Vector3f(xCoord,yCoord,0),0); xCoord += spacing.x; } yCoord += spacing.y; xCoord = 0; } // link the point masses with springs Geometry line; for (int y=0; y<numRows; y++) { for (int x=0; x<numColumns; x++) { if (x == 0 || y == 0 || x == numColumns-1 || y == numRows -1) { springList.add(new Spring(fixedPoints[x][y], points[x][y], 0.5f, 0.1f, gridNode, false, null)); } else if (x%3 == 0 && y%3 == 0) { springList.add(new Spring(fixedPoints[x][y], points[x][y], 0.005f, 0.02f, gridNode, false, null)); } if (x > 0) { if (y % 3 == 0) { line = thickLine; } else { line = defaultLine; } springList.add(new Spring(points[x-1][y], points[x][y], stiffness, damping, gridNode, true, line.clone())); } if (y > 0) { if (x % 3 == 0) { line = thickLine; } else { line = defaultLine; } springList.add(new Spring(points[x][y-1], points[x][y], stiffness, damping, gridNode, true, line.clone())); } } }
The first for
loop creates both regular masses and immovable masses at each intersection of the grid. We won't actually use all of the immovable masses, and the unused masses will simply be garbage collected at some point after the constructor ends. We could optimize by avoiding creating unnecessary objects, but since the grid is usually only created once, it won't make much difference.
In addition to using anchor point masses around the border of the grid, we will also use some anchor masses inside the grid. These will be used to very gently help pull the grid back to its original position after being deformed.
Since the anchor points never move, they don't need to be updated each frame. We can simply hook them up to the springs and forget about them. Therefore, we don't have a member variable in the Grid
class for these masses.
There are a number of values you can tweak in the creation of the grid. The most important ones are the stiffness and damping of the springs. The stiffness and damping of the border anchors and interior anchors are set independently of the main springs. Higher stiffness values will make the springs oscillate more quickly, and higher damping values will cause the springs to slow down faster.
There is one last thing to be mentioned: the createLine()
method.
private Geometry createLine(float thickness, AssetManager assetManager) { Vector3f[] vertices = {new Vector3f(0,0,0), new Vector3f(0,0,1)}; int[] indices = {0,1}; Mesh lineMesh = new Mesh(); lineMesh.setMode(Mesh.Mode.Lines); lineMesh.setLineWidth(thickness); lineMesh.setBuffer(VertexBuffer.Type.Position, 3, BufferUtils.createFloatBuffer(vertices)); lineMesh.setBuffer(VertexBuffer.Type.Index, 1, BufferUtils.createIntBuffer(indices)); lineMesh.updateBound(); Geometry lineGeom = new Geometry("lineMesh", lineMesh); Material matWireframe = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md"); matWireframe.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off); matWireframe.setColor("Color", new ColorRGBA(0.118f, 0.118f, 0.545f, 0.25f)); matWireframe.getAdditionalRenderState().setBlendMode(BlendMode.AlphaAdditive); lineGeom.setMaterial(matWireframe); return lineGeom; }
Here, we basically create a line by specifying the vertices of the line and the order of the vertices, creating a mesh, adding a blue material, and so on. If you want to understand the process of the line creation exactly, you can always take a look at the jME tutorials.
Manipulating the Grid
In order for the grid to move, we must update it each frame. This is very simple, as we already did all the hard work in the PointMass
and Spring
classes.
public void update(float tpf) { for (int i=0; i<springs.length; i++) { springs[i].update(tpf); } for (int x=0; x<points.length; x++) { for (int y=0; y<points[0].length; y++) { points[x][y].update(tpf); } } }
Now, we will add some methods that manipulate the grid. You can add methods for any kind of manipulation you can think of. We will implement three types of manipulations here: pushing part of the grid in a given direction, pushing the grid outwards from some point, and pulling the grid in towards some point. All three will affect the grid within a given radius from some target point.
Below are some images of these manipulations in action:
And here are the methods for the effects:
public void applyDirectedForce(Vector3f force, Vector3f position, float radius) { for (int x=0; x<points.length; x++) { for (int y=0; y<points[0].length; y++) { if (position.distanceSquared(points[x][y].getPosition()) < radius * radius) { float forceFactor = 10 / (10 + position.distance(points[x][y].getPosition())); points[x][y].applyForce(force.mult(forceFactor)); } } } } public void applyImplosiveForce(float force, Vector3f position, float radius) { for (int x=0; x<points.length; x++) { for (int y=0; y<points[0].length; y++) { float dist = position.distanceSquared(points[x][y].getPosition()); if (dist < radius * radius) { Vector3f forceVec = position.subtract(points[x][y].getPosition()); forceVec.multLocal(10f * force / (100 + dist)); points[x][y].applyForce(forceVec); points[x][y].increaseDamping(0.6f); } } } } public void applyExplosiveForce(float force, Vector3f position, float radius) { for (int x=0; x<points.length; x++) { for (int y=0; y<points[0].length; y++) { float dist = position.distanceSquared(points[x][y].getPosition()); if (dist < radius * radius) { Vector3f forceVec = position.subtract(points[x][y].getPosition()); forceVec.multLocal(-100f * force / (10000 + dist)); points[x][y].applyForce(forceVec); points[x][y].increaseDamping(0.6f); } } } }
Using the Grid in Shape Blaster
Now it's time to use the grid in our game. We start by declaring a Grid
variable in MonkeyBlasterMain
and initializing it in simpleInitApp()
:
Rectangle size = new Rectangle(0, 0, settings.getWidth(), settings.getHeight()); Vector2f spacing = new Vector2f(25,25); grid = new Grid(size, spacing, guiNode, assetManager);
Then, we need to call grid.update(float tpf)
from the simpleUpdate
method:
@Override public void simpleUpdate(float tpf) { if ((Boolean) player.getUserData("alive")) { spawnEnemies(); spawnBlackHoles(); handleCollisions(); handleGravity(tpf); } else if (System.currentTimeMillis() - (Long) player.getUserData("dieTime") > 4000f && !gameOver) { // spawn player player.setLocalTranslation(500,500,0); guiNode.attachChild(player); player.setUserData("alive",true); sound.spawn(); } grid.update(tpf); hud.update(); }
Next, we need to call the effect methods from the right places in our game.
The first one, creating a wave when the player spawns, is pretty easy—we just extend the place where we spawn the player in simpleUpdate(float tpf)
with the following line:
grid.applyDirectedForce(new Vector3f(0,0,5000), player.getLocalTranslation(), 100);
Note that we apply a force in the z-direction. We may have a 2D game but, since jME is a 3D engine, we can easily use 3D effects as well. If we were to rotate the camera, we'd see the grid bounce inwards and outwards.
The second and third effects need to be handled in controls. When bullets fly through the game, they call this method in controlUpdate(float tpf)
:
grid.applyExplosiveForce(direction.length()*(18f), spatial.getLocalTranslation(), 80);
This will make bullets repel the grid proportionally to their speed. That was pretty easy.
It's similar with the black holes:
grid.applyImplosiveForce(FastMath.sin(sprayAngle/2) * 10 +20, spatial.getLocalTranslation(), 250);
This makes the black hole suck in the grid with a varying amount of force. I reused the sprayAngle
variable, which will cause the force on the grid to pulsate in sync with the angle it sprays particles (although at half the frequency due to the division by two). The force passed in will vary sinusoidally between 10
and 30
.
In order to make this work, you must not forget to pass grid
to BulletControl
and BlackHoleControl
.
Interpolation
We can optimize the grid by improving the visual quality for a given number of springs without significantly increasing the performance cost.
We will make the grid denser by adding line segments inside the existing grid cells. We do so by drawing lines from the midpoint of one side of the cell to the midpoint of the opposite side. The image below shows the new interpolated lines in red:
We will create those additional lines in the constructor of our Grid
class. If you take a look at it, you'll see two for
loops where we link the point masses with the springs. Just insert this block of code there:
if (x > 0 && y > 0) { Geometry additionalLine = defaultLine.clone(); additionalLine.addControl(new AdditionalLineControl(points[x-1][y], points[x][y], points[x-1][y-1], points[x][y-1])); gridNode.attachChild(additionalLine); Geometry additionalLine2 = defaultLine.clone(); additionalLine2.addControl(new AdditionalLineControl(points[x][y-1], points[x][y], points[x-1][y-1], points[x-1][y])); gridNode.attachChild(additionalLine2); }
But, as you know, creating objects is not the only thing we need to do; we also need to add a control to them in order to get them to behave correctly. As you can see above, the AdditionalLineControl
gets four point masses passed to so it can calculate its position, rotation and scale:
public class AdditionalLineControl extends AbstractControl { private PointMass end11, end12, end21, end22; public AdditionalLineControl(PointMass end11, PointMass end12, PointMass end21, PointMass end22) { this.end11 = end11; this.end12 = end12; this.end21 = end21; this.end22 = end22; } @Override protected void controlUpdate(float tpf) { //movement spatial.setLocalTranslation(position1()); //scale Vector3f dif = position2().subtract(position1()); spatial.setLocalScale(dif.length()); //rotation spatial.lookAt(position2(),new Vector3f(1,0,0)); } private Vector3f position1() { return new Vector3f().interpolate(end11.getPosition(),end12.getPosition(),0.5f); } private Vector3f position2() { return new Vector3f().interpolate(end21.getPosition(),end22.getPosition(),0.5f); } @Override protected void controlRender(RenderManager rm, ViewPort vp) { } }
What's Next?
We have the basic gameplay and effects implemented. It's up to you to turn it into a complete and polished game with your own flavour. Try adding some interesting new mechanics, some cool new effects, or a unique story. In case you aren't sure where to start, here are a few suggestions:
- Create new enemy types such as snakes or exploding enemies.
- Create new weapon types such as seeking missiles or a lightning gun.
- Add a title screen and main menu.
- Add a high score table.
- Add some power-ups, such as a shield or bombs. For bonus points, get creative with your power-ups. You can make power-ups that manipulate gravity, alter time, or grow like organisms. You can attach a giant, physics-based wrecking ball to the ship to smash enemies. Experiment to find power-ups that are fun and help your game stand out.
- Create multiple levels. Harder levels can introduce tougher enemies and more advanced weapons and powerups.
- Allow a second player to join with a gamepad.
- Allow the arena to scroll so that it may be larger than the game window.
- Add environmental hazards such as lasers.
- Add a shop or leveling system, and allow the player to earn upgrades.
Thanks for reading!