In this tutorial series, I’ll explain how to create a game inspired by Geometry Wars, using the jMonkeyEngine. The jMonkeyEngine (“jME” for short) is an open source 3D Java game engine—find out more at their website or in our How to Learn jMonkeyEngine guide.
While the jMonkeyEngine is intrinsically a 3D game engine, it’s also possible to create 2D games with it.
This tutorial series is based on Michael Hoffman’s series explaining how to make the same game in XNA:The five chapters of the tutorial will be dedicated to certain components of the game:
- Initialize the 2D scene, load and display some graphics, handle input.
- Add enemies, collisions and sound effects.
- Add the GUI and black holes.
- Add some spectacular particle effects.
- Add the warping background grid.
As a little visual foretaste, here’s the final outcome of our efforts:
…And here are our results after this first chapter:
The music and sound effects you can hear in these videos were created by RetroModular, and you can read about how he did so here.
The sprites are by Jacob Zinman-Jeanes, our resident Tuts+ designer. All the artwork can be found in the source file download zip.
The tutorial is designed to help you learn the basics of the jMonkeyEngine and create your first game with it. While we will take advantage of the engine’s features, we won’t use complicated tools to improve the performance. Whenever there is a more advanced tool to implement a feature, I’ll link to the appropriate jME tutorials, but stick to the simple way in the tutorial itself. When you look into jME more, you will later be able to build on and improve your version of MonkeyBlaster.
Here we go!
Overview
The first chapter will include loading the necessary images, handling input, and making the player’s ship move and shoot.
To achieve this, we will need three classes:
MonkeyBlasterMain
: Our main class containing the game loop and the basic gameplay.PlayerControl
: This class will determine how the player behaves.BulletControl
: Similar to the above, this defines the behavior for our bullets.
During the course of the tutorial we will throw the general gameplay code in MonkeyBlasterMain
and manage the objects on the screen mainly through controls and other classes. Special features, like sound, will have their own classes as well.
Loading the Player’s Ship
If you haven’t downloaded the jME SDK yet, it’s about time! You can find it at the jMonkeyEngine homepage.
Create a new project in the jME SDK. It will automatically generate the main class, which will look similar to this one:
package monkeyblaster; import com.jme3.app.SimpleApplication; import com.jme3.renderer.RenderManager; public class MonkeyBlasterMain extends SimpleApplication { public static void main(String[] args) { Main app = new Main(); app.start(); } @Override public void simpleInitApp() { } @Override public void simpleUpdate(float tpf) { } @Override public void simpleRender(RenderManager rm) { } }
We’ll start by overriding simpleInitApp()
. This method gets called when the application starts. This is the place to set all the components up:
@Override public void simpleInitApp() { // setup camera for 2D games cam.setParallelProjection(true); cam.setLocation(new Vector3f(0,0,0.5f)); getFlyByCamera().setEnabled(false); // turn off stats view (you can leave it on, if you want) setDisplayStatView(false); setDisplayFps(false); }
First we’ll have to adjust the camera a bit since jME is basically a 3D game engine. The stats view in the second paragraph can be very interesting, but this is how you turn it off.
When you start the game now, you can see… nothing.
Well, we need to load the player into the game! We’ll create a little method to handle loading our entities:
private Spatial getSpatial(String name) { Node node = new Node(name); // load picture Picture pic = new Picture(name); Texture2D tex = (Texture2D) assetManager.loadTexture("Textures/"+name+".png"); pic.setTexture(assetManager,tex,true); // adjust picture float width = tex.getImage().getWidth(); float height = tex.getImage().getHeight(); pic.setWidth(width); pic.setHeight(height); pic.move(-width/2f,-height/2f,0); // add a material to the picture Material picMat = new Material(assetManager, "Common/MatDefs/Gui/Gui.j3md"); picMat.getAdditionalRenderState().setBlendMode(BlendMode.AlphaAdditive); node.setMaterial(new Material()); // set the radius of the spatial // (using width only as a simple approximation) node.setUserData("radius", width/2); // attach the picture to the node and return it node.attachChild(pic); return node; }
At the start we create a node which will contain our picture.
Tip: The jME scene graph consists of spatials (nodes, pictures, geometries, and so on). Whenever you add a spatial something to theguiNode
, it becomes visible in the scene. We will use the guiNode
because we’re creating a 2D game. You can attach spatials to other spatials and therefore organize your scene. To become a true master of the scene graph, I recommend this jME scene graph tutorial.After creating the node, we load the picture and apply the appropriate texture. Applying the right size to the picture is pretty is easy to understand, but why do we need to move it?
When you load a picture in jME, the center of rotation is not in the middle, but rather in a corner of the picture. But we can move the picture by half its width to the left and by half its height upwards, and add it to another node. Then, when we rotate the parent node, the picture itself is rotated around its center.
The next step is adding a material to the picture. A material determines how the picture will be displayed. In this example, we use the default GUI material and set the BlendMode
to AlphaAdditive
. This means overlapping transparent parts of multiple pictures will get brighter. This will be useful later to make explosions ‘shinier’.
Finally, we add our picture to the node and return it.
Now we have to add the player to the guiNode
. We’ll extend simpleInitApp
a little more:
// setup the player player = getSpatial("Player"); player.setUserData("alive",true); player.move(settings.getWidth()/2, settings.getHeight()/2, 0); guiNode.attachChild(player);
In short: We load the player, configure some data, move it to the middle of the screen, and attach it to the guiNode
to make it be displayed.
UserData
is simply some data you can attach to any spatial. In this case, we add a Boolean and call it alive
, so that we can look up whether the player is alive. We’ll use that later.
Now, run the program! You should be able to see the player in the middle. At the moment it’s pretty boring, I’ll admit. So let’s add some action!
Handling Input and Moving the Player
jMonkeyEngine input is pretty simple once you’ve done it once. We start by implementing an Action Listener:
public class MonkeyBlasterMain extends SimpleApplication implements ActionListener {
Now, for each key, we will add the input mapping and the listener in simpleInitApp()
:
inputManager.addMapping("left", new KeyTrigger(KeyInput.KEY_LEFT)); inputManager.addMapping("right", new KeyTrigger(KeyInput.KEY_RIGHT)); inputManager.addMapping("up", new KeyTrigger(KeyInput.KEY_UP)); inputManager.addMapping("down", new KeyTrigger(KeyInput.KEY_DOWN)); inputManager.addMapping("return", new KeyTrigger(KeyInput.KEY_RETURN)); inputManager.addListener(this, "left"); inputManager.addListener(this, "right"); inputManager.addListener(this, "up"); inputManager.addListener(this, "down"); inputManager.addListener(this, "return");
Whenever any of those keys is pressed or released, the method onAction
is called. Before we get into what to actually do when some key is pressed, we need to add a control to our player.
FightControl
and an IdleControl
to an enemy AI. Depending on the situation, you can enable and disable or attach and detach controls.Our PlayerControl
will simply take care of moving the player whenever a key is pressed, rotating it in the right direction and making sure the player does not leave the screen.
Here you go:
public class PlayerControl extends AbstractControl { private int screenWidth, screenHeight; // is the player currently moving? public boolean up,down,left,right; // speed of the player private float speed = 800f; // lastRotation of the player private float lastRotation; public PlayerControl(int width, int height) { this.screenWidth = width; this.screenHeight = height; } @Override protected void controlUpdate(float tpf) { // move the player in a certain direction // if he is not out of the screen if (up) { if (spatial.getLocalTranslation().y < screenHeight - (Float)spatial.getUserData("radius")) { spatial.move(0,tpf*speed,0); } spatial.rotate(0,0,-lastRotation + FastMath.PI/2); lastRotation=FastMath.PI/2; } else if (down) { if (spatial.getLocalTranslation().y > (Float)spatial.getUserData("radius")) { spatial.move(0,tpf*-speed,0); } spatial.rotate(0,0,-lastRotation + FastMath.PI*1.5f); lastRotation=FastMath.PI*1.5f; } else if (left) { if (spatial.getLocalTranslation().x > (Float)spatial.getUserData("radius")) { spatial.move(tpf*-speed,0,0); } spatial.rotate(0,0,-lastRotation + FastMath.PI); lastRotation=FastMath.PI; } else if (right) { if (spatial.getLocalTranslation().x < screenWidth - (Float)spatial.getUserData("radius")) { spatial.move(tpf*speed,0,0); } spatial.rotate(0,0,-lastRotation + 0); lastRotation=0; } } @Override protected void controlRender(RenderManager rm, ViewPort vp) {} // reset the moving values (i.e. for spawning) public void reset() { up = false; down = false; left = false; right = false; } }
Okay; now, let’s take a look at the code piece by piece.
private int screenWidth, screenHeight; // is the player currently moving? public boolean up,down,left,right; // speed of the player private float speed = 800f; // lastRotation of the player private float lastRotation; public PlayerControl(int width, int height) { this.screenWidth = width; this.screenHeight = height; }
First, we initialize some variables, defining in which direction and how fast the player is moving, and how far it is rotated. Then, we set the screenWidth
and screenHeight
, which we will need in the next big method.
controlUpdate(float tpf)
is automatically called by jME every update cycle. The variable tpf
indicates the time since the last update. This is needed to control the speed: If some computers take twice as long to calculate an update as others, then the player should move twice as far in a single update on those computers.
Now to the first if
statement:
if (up) { if (spatial.getLocalTranslation().y < screenHeight - (Float)spatial.getUserData("radius")) { spatial.move(0,tpf*speed,0); }
We check whether the player is going up and, if so, we check if it can go up any further. If it is far enough away from the border, we simply move it up a little.
Now onto the rotation:
spatial.rotate(0,0,-lastRotation + FastMath.PI/2); lastRotation=FastMath.PI/2;
We rotate the player back by lastRotation
to face its original direction. From this direction, we can rotate the player in the direction we want it to look at. Finally, we save the actual rotation.
We use the same kind of logic for all four directions. The reset()
method is just here to set all values to zero again, for use when respawning the player.
So, we finally have the control for our player. It’s time to add it to the actual spatial. Simply add the following line to the simpleInitApp()
method:
player.addControl(new PlayerControl(settings.getWidth(), settings.getHeight()));
The object settings
is included in the class SimpleApplication
. It contains data about the display settings of the game.
If we start the game now, still nothing is happening yet. We need to tell the program what to do when one of the mapped keys is pressed. To do this, we’ll override the onAction
method:
public void onAction(String name, boolean isPressed, float tpf) { if ((Boolean) player.getUserData("alive")) { if (name.equals("up")) { player.getControl(PlayerControl.class).up = isPressed; } else if (name.equals("down")) { player.getControl(PlayerControl.class).down = isPressed; } else if (name.equals("left")) { player.getControl(PlayerControl.class).left = isPressed; } else if (name.equals("right")) { player.getControl(PlayerControl.class).right = isPressed; } } }
For each pressed key, we tell the PlayerControl
the new status of the key. Now it’s finally time to start our game and see something moving on the screen!
When you are happy that you understand the basics of input and behavior management, it’s time to do the same thing again—this time, for the bullets.
Adding Some Bullet Action
If we want to have some real action going on, we need to be able to shoot some enemies. We’re going to follow the same basic procedure as in the previous step: managing input, creating some bullets and adding a behavior to them.
In order to handle mouse input, we’ll implement another listener:
public class MonkeyBlasterMain extends SimpleApplication implements ActionListener, AnalogListener {
Before anything happens, we need to add the mapping and the listener like we did last time. We’ll do that in the simpleInitApp()
method, alongside the other input initialization:
inputManager.addMapping("mousePick", new MouseButtonTrigger(MouseInput.BUTTON_LEFT)); inputManager.addListener(this, "mousePick");
Whenever we click with the mouse, the method onAnalog
gets called. Before we get into the actual shooting, we need to implement a little helper method, Vector3f getAimDirection()
, which will give us the direction to shoot at by subtracting the position of the player from that of the mouse:
private Vector3f getAimDirection() { Vector2f mouse = inputManager.getCursorPosition(); Vector3f playerPos = player.getLocalTranslation(); Vector3f dif = new Vector3f(mouse.x-playerPos.x,mouse.y-playerPos.y,0); return dif.normalizeLocal(); }Tip: When attaching objects to the
guiNode
, their local translation units are equal to one pixel. This makes it easy for us to calculate the direction, since the cursor position is specified in pixel units as well.Now that we have a direction to shoot at, let’s implement the actual shooting:
public void onAnalog(String name, float value, float tpf) { if ((Boolean) player.getUserData("alive")) { if (name.equals("mousePick")) { //shoot Bullet if (System.currentTimeMillis() - bulletCooldown > 83f) { bulletCooldown = System.currentTimeMillis(); Vector3f aim = getAimDirection(); Vector3f offset = new Vector3f(aim.y/3,-aim.x/3,0); // init bullet 1 Spatial bullet = getSpatial("Bullet"); Vector3f finalOffset = aim.add(offset).mult(30); Vector3f trans = player.getLocalTranslation().add(finalOffset); bullet.setLocalTranslation(trans); bullet.addControl(new BulletControl(aim, settings.getWidth(), settings.getHeight())); bulletNode.attachChild(bullet); // init bullet 2 Spatial bullet2 = getSpatial("Bullet"); finalOffset = aim.add(offset.negate()).mult(30); trans = player.getLocalTranslation().add(finalOffset); bullet2.setLocalTranslation(trans); bullet2.addControl(new BulletControl(aim, settings.getWidth(), settings.getHeight())); bulletNode.attachChild(bullet2); } } } }
Okay, so, let’s go through this:
if (System.currentTimeMillis() - bulletCooldown > 83f) { bulletCooldown = System.currentTimeMillis(); Vector3f aim = getAimDirection(); Vector3f offset = new Vector3f(aim.y/3,-aim.x/3,0);
If the player is alive and the mouse button is clicked, our code first checks whether the last shot was fired at least 83 ms ago (bulletCooldown
is a long variable we initialize at the beginning of the class). If so, then we are allowed to shoot, and we calculate the right direction for aiming and the offset.
// init bullet 1 Spatial bullet = getSpatial("Bullet"); Vector3f finalOffset = aim.add(offset).mult(30); Vector3f trans = player.getLocalTranslation().add(finalOffset); bullet.setLocalTranslation(trans); bullet.addControl(new BulletControl(aim, settings.getWidth(), settings.getHeight())); bulletNode.attachChild(bullet); // init bullet 2 Spatial bullet2 = getSpatial("Bullet"); finalOffset = aim.add(offset.negate()).mult(30); trans = player.getLocalTranslation().add(finalOffset); bullet2.setLocalTranslation(trans); bullet2.addControl(new BulletControl(aim, settings.getWidth(), settings.getHeight())); bulletNode.attachChild(bullet2);
We want to spawn twin bullets, one next to the other, so we’ll have to add a little offset to each of them. An appropriate offset is orthogonal to the aim direction, which is easily achieved by switching the x
and y
values and negating one of it. The second one will simply be a negation of the first one.
// init bullet 1 Spatial bullet = getSpatial("Bullet"); Vector3f finalOffset = aim.add(offset).mult(30); Vector3f trans = player.getLocalTranslation().add(finalOffset); bullet.setLocalTranslation(trans); bullet.addControl(new BulletControl(aim, settings.getWidth(), settings.getHeight())); bulletNode.attachChild(bullet); // init bullet 2 Spatial bullet2 = getSpatial("Bullet"); finalOffset = aim.add(offset.negate()).mult(30); trans = player.getLocalTranslation().add(finalOffset); bullet2.setLocalTranslation(trans); bullet2.addControl(new BulletControl(aim, settings.getWidth(), settings.getHeight())); bulletNode.attachChild(bullet2);
The rest should seem pretty familiar: We initialize the bullet by using our own getSpatial
method from the beginning. Then we translate it to the right place and attach it to the node. But wait, what node?
We’ll organize our entities in specific nodes, so it makes sense to create a node where we’ll be able to attach all our bullets to. To display the children of that node, we’ll have to attach it to the guiNode
.
The initialization in simpleInitApp()
is pretty straightforward:
// setup the bulletNode bulletNode = new Node("bullets"); guiNode.attachChild(bulletNode);
If you go ahead and start the game, you’ll be able to see the bullets appearing, but they’re not moving! If you want to test yourself, pause reading and think for yourself what we need to do in order to make them move.
…
Did you figure it out?
We need to add a control to each bullet that’ll take care of its movement. To do this, we’ll create another class called BulletControl
:
public class BulletControl extends AbstractControl { private int screenWidth, screenHeight; private float speed = 1100f; public Vector3f direction; private float rotation; public BulletControl(Vector3f direction, int screenWidth, int screenHeight) { this.direction = direction; this.screenWidth = screenWidth; this.screenHeight = screenHeight; } @Override protected void controlUpdate(float tpf) { // movement spatial.move(direction.mult(speed*tpf)); // rotation float actualRotation = MonkeyBlasterMain.getAngleFromVector(direction); if (actualRotation != rotation) { spatial.rotate(0,0,actualRotation - rotation); rotation = actualRotation; } // check boundaries Vector3f loc = spatial.getLocalTranslation(); if (loc.x > screenWidth || loc.y > screenHeight) { spatial.removeFromParent(); } } @Override protected void controlRender(RenderManager rm, ViewPort vp) {} }
A quick glance at the structure of the class shows that it is pretty similar to the PlayerControl
class. The main difference is that we don’t have any keys to be checked, and we do have a direction
variable. We simply move the bullet in its direction and rotate it accordingly.
Vector3f loc = spatial.getLocalTranslation(); if (loc.x screenWidth || loc.y > screenHeight) { spatial.removeFromParent(); }
In the last block, we check whether the bullet is outside the screen boundaries and, if so, we remove it from its parent node, which will delete the object.
You may have caught this method call:
MonkeyBlasterMain.getAngleFromVector(direction);
It refers to a short static mathematical helper method in the main class. I created two of them, one converting an angle in a vector in 2D space and the other converting such vectors back in an angle value.
public static float getAngleFromVector(Vector3f vec) { Vector2f vec2 = new Vector2f(vec.x,vec.y); return vec2.getAngle(); } public static Vector3f getVectorFromAngle(float angle) { return new Vector3f(FastMath.cos(angle),FastMath.sin(angle),0); }Tip: If you feel quite confused by all those vector operations, then do yourself a favour and dig in to some tutorials about vector math. It’s essential in both 2D and 3D space. While you’re at it, you should also look up the difference between degrees and radians. And if you want to get more into 3D game programming, quaternions are awesome as well…
Now back to the main overview: We created an input listener, initialized two bullets, and created a BulletControl
class. The only thing left is to add a BulletControl
to each bullet when initializing it:
bullet.addControl(new BulletControl(aim, settings.getWidth(), settings.getHeight()));
Now the game is much more fun!
Conclusion
While it isn’t exactly challenging to fly around and shoot some bullets, you can at least do something. But don’t despair—after the next tutorial you’ll have a hard time trying to escape the growing hordes of enemies!