Wednesday, March 6, 2013

The Game Map (Level 2)

While what we have so far is kind of cute, it doesn't look like much of a game just yet.  As with any decent top down, tile based, 2D RPG, we need some awesome looking maps.  For right now, I don't have a perfect idea of what it should look like, but I've decided to expand myself a bit and make it a hex based grid, instead of square tiles.

The first thing I wanted was a set of terrain tile sprites, which I threw together in Paint.NET.  I based my tiles on a 46 x 39 pixel square, because I wanted my hexagons to be as close to regular hexagons as possible, which requires some rounding on a computer (pesky sqrt(3))!

Anyway, I chose to make tiles for deep water, shallow water, beach/desert, plains, light forest, forest, hills, mountains, and tall mountain peaks.  I named the files hex_0.png through hex_8.png to make them load into a single object with the ImagePacker.  Because I actually drew these, I suppose I should mention something about a license.  I like the creative commons attribution license, so use them however you like as long as you give me credit!  As for my code, use it however you like too.

Creative Commons License
This work is licensed under a Creative Commons Attribution 3.0 Unported License.


I put these images in a folder textures/maptiles in GameXYZ-desktop.  They were designed with partially transparent edges so they could overlap and show some natural looking grid lines.  Here's an example of a random map I generated and rendered with these tiles (click for full size):

Actually, that's just a small portion of the whole, which looks like:

This is really just a first stab at the terrain generation, but I think it's okay for now.  We'll talk about how to make it in a later terrain generation post.  For now though let's focus on how we store and render it.

The data for the map is stored in a 2D array of ints, where each int corresponds to a terrain type (0=deep water, 1=shallow water, etc...).  Unlike a typical 2D tile map, which is just about perfectly represented by a 2D array, the hexmap needs a little tweaking.  Whereas a typical square tile map might look like this:

To turn it into a hexmap, we can shift every other column up by half a cell to look like this:

Of course we won't want to render our tiles to look like squares, but as far as the 2D int array is concerned, this is how it will be rendered.

Notice a frustrating problem with this though, whereas in the square array it's very easy to find a cells neighbors, it is less trivial in the hex map.  Consider cell (1,1).  It has a neighbor of (2,2), or (1+1, 1+1).  However, (4,3) does NOT have a neighbor at (4+1,3+1)=(5,4).  The problem comes with the upward shift of every other column.  So when we are navigating the map, or getting cell neighbors, we'll have to be very careful how we do it.

UPDATE:
The following no longer reflects how I implemented it.  Very shortly after writing this, I realized it stunk as I tried to implement pathfinding.  The general concept is the same, but it is not a Component and Entity thing, it is just a stand alone class called GameMap.java which has a field declared in GameXYZ.java.  To see how it is done more recently, check out the pathfinding post here.
 
To actually implement this, I made a new Component called GameMap, and a new EntitySystem called MapRenderSystem.  At the start of the game I'll create an entity with the GameMap component, which will store the 2D array, plus whatever other crap I can think of as necessary, and every cycle the MapRenderSystem will render it (much like SpriteRenderSystem).  I'll specifically call MapRenderSystem first, so that sprites get rendered on top of it.  I also create a few other helper classes.  Here's what they look like:
package com.gamexyz.components;

import com.artemis.Component;

public class GameMap extends Component {
 public int[][] map;
 public int width, height;
 
 public GameMap() {
  map = new int[][] {
    { 0, 1, 2, 3, 4, 5, 6, 7, 8 },
    { 0, 0, 0, 0, 0, 0, 0, 0, 0 },
    { 0, 0, 0, 0, 0, 0, 0, 0, 0 },
    { 1, 1, 1, 1, 1, 1, 1, 1, 1 },
    { 1, 1, 1, 1, 1, 1, 1, 1, 1 },
    { 2, 2, 2, 2, 2, 2, 2, 2, 2 },
    { 2, 2, 2, 2, 2, 2, 2, 2, 2 },
    { 3, 3, 3, 3, 3, 3, 3, 3, 3 },
    { 3, 3, 3, 3, 3, 3, 3, 3, 3 }
  };
  width = map.length;
  height = map[0].length;
  
 }
}
Here we just have some crappy predefined map, plus info on the width and height.  The RenderSystem looks like this:
package com.gamexyz.systems;

import com.artemis.Aspect;
import com.artemis.ComponentMapper;
import com.artemis.Entity;
import com.artemis.EntitySystem;
import com.artemis.annotations.Mapper;
import com.artemis.utils.ImmutableBag;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.graphics.g2d.TextureAtlas;
import com.badlogic.gdx.graphics.g2d.TextureAtlas.AtlasRegion;
import com.badlogic.gdx.graphics.g2d.TextureRegion;
import com.badlogic.gdx.utils.Array;
import com.gamexyz.components.GameMap;
import com.gamexyz.utils.MapTools;

public class MapRenderSystem extends EntitySystem {
 @Mapper ComponentMapper<GameMap> gm;
 private SpriteBatch batch;
 private TextureAtlas atlas;
 private Array<AtlasRegion> textures;

 private OrthographicCamera camera;
 
 @SuppressWarnings("unchecked")
 public MapRenderSystem(OrthographicCamera camera) {
  super(Aspect.getAspectForAll(GameMap.class));
  this.camera = camera;
 }
 
 @Override
 protected void initialize() {
  batch = new SpriteBatch();
  
  atlas = new TextureAtlas(Gdx.files.internal("textures/maptiles.atlas"),Gdx.files.internal("textures"));
  textures = atlas.findRegions(MapTools.name); 
 }

 @Override
 protected boolean checkProcessing() {
  return true;
 }

 @Override
 protected void processEntities(ImmutableBag<Entity> entities) {
  for (int i = 0; i < entities.size(); i++) process(entities.get(i));
 }
 
 private void process(Entity e) {
  GameMap gameMap = gm.get(e);
  TextureRegion reg;
  int x, y;

  int x0 = 0;
  int x1 = gameMap.width;
  
  int y0 = 0;
  int y1 = gameMap.height;
  
  // Loop over everything in the window to draw
  // Because I am drawing a hexmap tile, I chose to 
  // do 2 columns at once. As such, I had to
  // stop shy of the far right column, because
  // col+1 would break for it.  Thus we do that
  // final column separately.
  for (int row = y0; row < y1; row++) {
   for (int col = x0; col < x1-1; col+=2) {
    x = col*MapTools.col_multiple;
    y = row*MapTools.row_multiple;
    reg = textures.get(gameMap.map[col][row]);
    batch.draw(reg, x, y, 0, 0, reg.getRegionWidth(), reg.getRegionHeight(), 1, 1, 0);
    x += MapTools.col_multiple;
    y += MapTools.row_multiple/2;
    reg = textures.get(gameMap.map[col+1][row]);
    batch.draw(reg, x, y, 0, 0, reg.getRegionWidth(), reg.getRegionHeight(), 1, 1, 0);
   }
   if (x1 >= gameMap.width) {
    int col = gameMap.width-1;
    x = col*MapTools.col_multiple;
    y = row*MapTools.row_multiple;
    reg = textures.get(gameMap.map[col][row]);
    batch.draw(reg, x, y, 0, 0, reg.getRegionWidth(), reg.getRegionHeight(), 1, 1, 0);
   }
   
  }
 }
 
 @Override
 protected void begin() {
  batch.setProjectionMatrix(camera.combined);
  batch.begin();
 }
 
 @Override
 protected void end() {
  batch.end();
 }
}
Here, when it's initialized, it stores the map textures in an Array called textures (lines 37-38).  Because every 2nd column has to be shifted up by half a cell, I had a choice between rendering two columns at once, and manually putting the 2nd one up a bit, or rendering each column individually and checking to see if we were on an even or odd column every time.  I chose the first option (lines 70-77).  My random map generation method ends up with an odd number of columns overall, however, so I had to render the last one separately (lines 79-85).

I call MapTools.col_multiple and MapTools.row_multiple to get the row and column offsets that each hex cell should be drawn at.  Because they are hex cells, the images have to overlap a bit to line up properly, and those constants store that information.  MapTools.name is just a string "hex", because I wanted to potentially have the flexibility to someday also do square tiles.  The MapTools class went into com.gamexyz.utils
package com.gamexyz.utils;

import com.gamexyz.custom.Pair;

public class MapTools {
 
 public static final int col_multiple = 34;
 public static final int row_multiple = 38;
 public static final String name = "hex";
 
 

 public static Pair[] getNeighbors(int x, int y, int n) {
  Pair[] coordinates = new Pair[3*(n*n + n)];
  int i = 0;
  int min;
  for (int row = y-n; row<y+n+1; row++) {
   min = MyMath.min(2*(row-y+n), n, -2*(row-y-n)+1);
   for (int col = x-min; col < x+min+1; col++) {
    if (x==col && y==row) continue;
    else if (x % 2 == 0) coordinates[i]=new Pair(col,2*y-row);
    else coordinates[i] = new Pair(col,row);
    i++;
   }
  }
  return coordinates;
 }
 
 public static Pair[] getNeighbors(int x, int y) {
  return getNeighbors(x,y,1);
 }
}
That getNeighbors method was a nightmare to create.  I'm certainly not sure that it's the best way to go about it, but I've tested it and it works.  It returns the neighbors as an array of Pairs, which I had to define in com.gamexyz.custom
package com.gamexyz.custom;

public class Pair {
 public Pair(int x, int y) {
  this.x = x;
  this.y = y;
 }
 public int x, y;
}

Also, a few of these classes reference something called MyMath, which is just a collection of a few math methods I put together in com.gamexyz.utils.  They're not the most general, but they get the job done quickly.
package com.gamexyz.utils;

public class MyMath {
 public static int min(int a, int b) {
  if (a < b) return a;
  return b;
 }
 
 public static int min(int a, int b, int c) {
  if (min(a,b) < c) return min(a,b);
  return c;
 }
 
 public static int pow(int a, int b) {
  if (b > 1) return a*pow(a,b-1);
  else return a;
 }
}

Remember to register the MapRenderSystem and create an Entity with the component for GameMap, and also remember to manually process MapRenderSystem. With that, you are now drawing a game map in hex tiles! These methods should be easy to customize if you want square tiles instead.

There are a couple of crappy things going on here though.  First, this code always renders the ENTIRE map... not just the portion it needs.  That process is called Frustum Culling, and is actually super easy!  For small maps, this isn't so important, but for larger maps it will make a difference.  We'll talk about it in the next article.

Also, it would be super nice to be able to click/drag and move the map, and maybe even use a scroll wheel to zoom in and out.  These are also pretty easy and will be discussed next time.

Also, you can design your own maps by hand, but that is PAINSTAKINGLY slow I think.  I implemented the Diamond-Square fractal terrain generation algorithm, it's super fast, and I'll post that along with its inner workings coming soon.

I will probably also start a Google Code repository soon so people can come and download the code in case something doesn't work so well because I forgot some minor thing I tweaked in another file.

You have gained 100 XP.  Progress to Level 3: 200/600

No comments:

Post a Comment

Post a Comment