Writing a Tile Engine in JavaFX
With the advent of embedded versions of JavaFX, our framework has become more interesting for game development, since we now can target small consumer devices like tablets & smartphones. So I decided to do a little more experimenting with JavaFX for writing Games. This time I wanted to use Canvas to have more control over the rendering in order to be able to optimize the performance on smaller devices. These are my experiences when writing a Tile Engine.
What’s a Tile Engine?
Back in the early days game consoles & computers had very limited resources. So in order to have games with thousands of large screens developers needed to come up with a method to store the screens in a format other than a bitmap per screen. So Tile Engines were invented that can generate large screens from a limited set of re-usable smaller graphics (Tiles). This saves ram and improves rendering performance.
TileMaps
The instructions how to generate the screen are stored in TileMaps. Those maps are typically organized as a 2-dimensional matrix of Tile ids. Usually the tiles are organized in layers to allow for a simple Z-ordering and more flexibility in combining graphics with different backgrounds. Usually TileMaps also support storing of meta data, for example if certain tiles are blocked, or spawn points for enemies.
TileSets
The tiles referenced in the map are usually stored in TileSets that consist of a single bitmap and meta information about how to divide it into tiles. Here’s an example of such an image from opengameart.com, a site that hosts game assets with Open Source Licences. In my examples I use some of these graphics.
ObjectGroups
One additional feature of the TMX format are Object Layers. These special layers can be used to define freeform shapes and polylines and assign properties to them. The basic idea behind that is that we can use them to define areas where Sprites are created (spawnpoints), exits, portals, and non-rectangular collision shapes. It’s up to the creator of the TileEngine or the developer who builds games with it to define how to handle the ObjectGroups. I’m planning to use them extensively, and they are a very nice extension point for declaratively defining the gameplay. You can e.g. use them to define animations, skript dialogs, etc..
Workflow, Tools & Formats
The idea of tilemaps also allows for a nice workflow. Graphic designers can create the assets and game designers can import them into a level editor like “Tiled” and design the levels via drag & drop. The maps are stored in a machine readable TileMap format. Tiled for example uses the TMX Map format for storing the TileMap. That’s a very simple XML format, that can then be loaded by the TileEngine. For my implementation I decided to use the TMX Format, so I can use “Tiled” for designing the levels.
Implementation in JavaFX
For the implementation I decided to use JavaFX Canvas immediate mode rendering instead of the retained mode rendering when using individual Nodes. This gives me a bit more control for optimizing the performance on small devices like the Raspberry Pi.
Reading TMX/TSX files
The first thing we need is a way to read the TileMap (TMX) and TileSet (TSX) files. With JAXB it’s pretty simple to create a TileMapReader that can create POJOs from a file. So if you use the Engine you simply call:
TileMap map = TileMapReader.readMap(“path/to/my/map.tmx”);
The Camera
Since in most games the TileMaps will be larger than the screen, only a part of the Map is rendered. Usually the map is centered on the hero. You can do that by simply tracking the map position of upper left corner of the screen. We refer to this as our Camera position. The position is then updated from the hero’s position just before the TileMap is rendered like this:
// the center of the screen is the preferred location of our hero double centerX = screenWidth / 2; double centerY = screenHeight / 2; cameraX = hero.getX() - centerX; cameraY = hero.getY() - centerY;
We just need to make sure the camera doesn’t leave the tilemap:
// if we get too close to the borders if (cameraX >= cameraMaxX) { cameraX = cameraMaxX; } if (cameraY >= cameraMaxY) { cameraY = cameraMaxY; }
Rendering the TileMap using Canvas
Then it’s really easy to render the tiles. we simply loop through the layers and ask the tilemap to render the correct image at the current position. First we need to find out which tiles are currently visible, and the offset, since our hero moves pixel by pixel, not tile by tile:
// x,y index of first tile to be shown int startX = (int) (cameraX / tileWidth); int startY = (int) (cameraY / tileHeight); // the offset in pixels int offX = (int) (cameraX % tileWidth); int offY = (int) (cameraY % tileHeight); Then we loop through the visible layers and draw the tile: for (int y = 0; y < screenHeightInTiles; y++) { for (int x = 0; x < screenWidthInTiles; x++) { // get the tile id of the tile at this position int gid = layer.getGid((x + startX) + ((y + startY) * tileMap.getWidth())); graphicsContext2D.save(); // position the graphicscontext for drawing graphicsContext2D.translate((x * tileWidth) - offX, (y * tileHeight) - offY); // ask the tilemap to draw the tile tileMap.drawTile(graphicsContext2D, gid); // restore the old state graphicsContext2D.restore(); } }
The TileMap will then find out which Tileset the Tile belongs to and ask the TileSet to draw it to the Context. Drawing itself is as simple as finding the correct coordinates in your TileSets Image:
public void drawTile(GraphicsContext graphicsContext2D, int tileIndex) { int x = tileIndex % cols; int y = tileIndex / cols; // TODO support for margin and spacing graphicsContext2D.drawImage(tileImage, x * tilewidth, y* tileheight, tilewidth, tileheight, 0, 0, tilewidth, tileheight); }
Game Loop. So we can simplify it to:
The Game Loop is again very simple. I’m using a TimeLine and a KeyFrame to fire a pulse for the game at a certain framerate (FPS):
final Duration oneFrameAmt = Duration.millis(1000 / FPS); final KeyFrame oneFrame = new KeyFrame(oneFrameAmt, new EventHandler() { @Override public void handle(Event t) { update(); render(); } }); TimelineBuilder.create() .cycleCount(Animation.INDEFINITE) .keyFrames(oneFrame) .build() .play();
Sprites
Every call to update in the TileMapCanvas loops through all Sprites and updates them. Basic Sprites currently contain one TileSet with a walkcycle like this:
Since sprites typically have a lot of transparent space around them, in order to have some extra room for animated behavior like like swinging a sword, I decided to allow to add a MoveBox and a CollisionBox for convenience. The CollisionBox can be used to define an area where our hero can be hurt. The MoveBox should be placed around the legs, so it can pass in front of forbidden tiles while the upper body is overlapping the tile. The blueish area around our “hero” is the sprite boundary:
https://www.youtube.com/watch?v=08H6LZkcqXw
Sprites can also have a timed Behavior. On every update the Sprite loops through it’s behaviors and checks if it’s time to fire. If so it’s “behave” method is called. If we have an enemy, like the skeleton in the sample app, we can add it’s AI here. Our Skeleton has for example a very simple behavior to make it follow our hero. It also checks for collision and causes damage to our hero like that:
monsterSprite.addBehaviour(new Sprite.Behavior() { @Override public void behave(Sprite sprite, TileMapCanvas playingField) { if (sprite.getCollisionBox().intersects(hero.getCollisionBox())) { hero.hurt(1); } } });
The default interval is a second. If you need other intervals you can set them. Behaviors are reusable, different sprites can share the same Behavior instance. Behaviors are similar to KeyFrames, and I’m currently also using them to time the Animations (increase the tile index for the next render call).
ObjectGroupHandler
As mentioned in the beginning ObjectGroups are handy extension points. In my example game I use them for defining the spawn points of our hero and the monsters. Currently you simply add an ObjectGroupHandler which in turn uses the information in the ObjectGroup to create the Hero and Monster sprites and add Behavior to them:
class MonsterHandler implements ObjectGroupHandler { Sprite hero; @Override public void handle(ObjectGroup group, final TileMapCanvas field) { if (group.getName().equals('sprites')) { for (TObject tObject : group.getObjectLIst()) { if (tObject.getName().equals('MonsterSpawner')) { try { double x = tObject.getX(); double y = tObject.getY(); TileSet monster = TileMapReader.readSet('/de/eppleton/tileengine/resources/maps/BODY_skeleton.tsx'); Sprite monsterSprite = new Sprite(monster, 9, x, y, 'monster'); monsterSprite.setMoveBox(new Rectangle2D(18, 42, 28, 20)); field.addSprite(monsterSprite); monsterSprite.addBehaviour(new Sprite.Behavior() { @Override public void behave(Sprite sprite, TileMapCanvas playingField) { if (sprite.getCollisionBox().intersects(hero.getCollisionBox())) { hero.hurt(1); } } }); }
Putting it all together
To create a sample game all you need to do is create TileMaps, TileSets, one or more ObjectGroupHandler(s) to create the Sprites and add Behavior, and you’re ready to play:
// create the world TileMap tileMap = TileMapReader.readMap('/de/eppleton/tileengine/resources/maps/sample.tmx'); // initialize the TileMapCanvas TileMapCanvas playingField = new TileMapCanvas(tileMap, 0, 0, 500, 500); // add Handlers, can also be done declaratively. playingField.addObjectGroupHandler(new MonsterHandler()); // display the TileMapCanvas StackPane root = new StackPane(); root.getChildren().add(playingField); Scene scene = new Scene(root, 500, 500); playingField.requestFocus(); primaryStage.setTitle('Tile Engine Sample'); primaryStage.setScene(scene); primaryStage.show();
That was the starting point of my Tile Engine. In the meantime it has evolved a bit into a more general purpose 2D-engine, so also Sprites that are not using TileSets and Layers that are freely rendered are supported. But it works pretty well so far.
Reference: Writing a Tile Engine in JavaFX from our JCG partner Toni Epple at the Eppleton blog.
how to install Library supporting TileMap
i do not know good sir but i do enjoy java
You need to specify your problem and provide a minimum complete and verifiable example or no one can help you.