понедельник, 30 января 2017 г.

Working with tiles in code. Theory

When we do have a business with tiles in the games we make it should be very important to  know how to work with them effectively, i.e not to make your code look like a bunch of if-then-else statements which you could measure as hundreds of lines of code. This kind of code would be very hard to maintain even a couple of days after you have written it!

Okay, so what am I supposed to do to avoid this problem?

The solution which I have chosen is a bit masks. Every tile you place on a tilemap could be related to special bit mask so it could be more obvious how to choose the right tile. There is many types of this masks you could introduce actually, but the idea will be quite common. I’ll explain it in the several examples.

Example 1. The road tilemap

For more information on this kind of tiles look here

In this example we have fifteen tiles (sixteen actually, but blank tile aren’t shown in the picture), and every tile have only a four base properties. These properties could be explained as road connected from the top, from the bottom, from the left or from the right.

In the naive way we would write something like this to choose a desired tile:
local tile = 0
local from_top = map.get(x, y + 1) ~= 0
local from_right = map.get(x + 1, y) ~= 0
local from_bottom = map.get(x, y - 1) ~= 0
local from_left = map.get(x - 1, y) ~= 0
if from_top then
   tile = 1
elseif from_right then
   tile = 2
elseif from_top and from_right then
   tile = 3
elseif from_bottom then
   tile = 4
elseif from_top and from_bottom then
   tile = 5
-- similar code goes there
elseif from_top and from_right and from_bottom and from_left then
   tile = 15
end

This is very bad practice, it was hard to write the first time and it would be even harder to edit in the future.
This is how we could modify this code:
local north = 1 -- 0001
local east = 2 -- 0010
local south = 4 -- 0100
local west = 8 -- 1000
local tile = 0
if map.get(x, y + 1) ~= 0 then
   tile = tile + north
end
if map.get(x + 1, y) ~= 0 then
   tile = tile + east
end
if map.get(x, y - 1) ~= 0 then
   tile = tile + south
end
if map.get(x - 1, y) ~= 0 then
   tile = tile + west
end

I would guarantee that this piece of code will be readable whenever you will look at it. Even if it’s not your own code! So, the magic is on the binary numbers. Each side of a road have been represented by a bit in the resulting tile number. For the north it’s 1 or 0001 in binary, for the east it would be 2 or 0010 and so on. When we sum these bits together we get 16 unique combinations, each one will represent the single tile with corresponding properties. All that remains to do is to organize our tileset layout in the right way.

But what if our tileset layout does not fit with what is mentioned in this article? We could always solve it by the lookup table in one single place in our code. Yes, it will be something that looks similar to the first example, but it could be split out of the main code in some simple module, and even be generated by some special tools, so this is not so big problem.

Example 2. The terrain tilemap

For more information on terrain tiles look here
The difference is in properties which we are interested in. For the terrain we are more interested in our corner neighbours.

Here we are also have a set of 15 tiles, but we are looking for the corners of each tiles instead of sides and getting the terrainy looking tile behaviour as a result.

This is also exactly the same technique that is used in marching squares algorithm.

For this approach to work your map should be organized in a grid where each cell has four corners. These corners are collinear to the neighboring cells. The code for choosing the right tile will look close to this:
local nord_west = 1 -- 0001
local nord_east = 2 -- 0010
local south_east  = 4 -- 0100
local south_west = 8 -- 1000
local tile = 0
local map_cell =  map.get(x, y)

if map_cell.nord_west_corner ~= 0 then
   tile = tile + nord_west
end
if map_cell.nord_east_corner ~= 0 then
   tile = tile + nord_east
end
if map_cell.south_east_corner ~= 0 then
   tile = tile + south_east
end
if map_cell.south_west_corner ~= 0 then
   tile = tile + south_west
end

Example 3. The snake game tilemap

This is actually a modified version of the road tilemap with some tricks.
First, every tile could have up to a couple of neighbors and no more, tiles which have only one neighbor are the tail and the head and we need to know which part of the body it is, so we could use additional bits 16(010000 in binary) for the head and 32(100000 in binary) for the tail. Actually, we could even use only one bit as a flag, but I’ve chosen a variant with two bits for the tutorial and in the practice part you will have exactly this variant of snake implementation.
So, the snake is like a road, but it has additional flags for the head and for the tail, and a tileset is looking like that:
Please note that this variant’s indices doesn’t match the 0..15 range, and you should use some lookup table to map bitmask to the right tiles.
For this example lookup is next:
local north = 1 -- 000001
local east = 2 -- 000010
local south = 4 -- 000100
local west = 8 -- 001000
local head= 16 -- 010000
local tail= 32 -- 100000

local snake_lookup =
   {[head + south] = 0, [head + west] = 1, [head + north] = 2, [head + east] = 3,
    [north + south] = 4, [east + west] = 5,  
    [east + south] = 8, [west + south] = 9, [tail + south] = 10, [tail + east] = 11,  
    [east + north] = 12, [west + north] = 13, [tail + north] = 14, [tail + west] = 15}

Summary

As you can see there is many different tileset layouts available, but you always could analyze these layouts for a limited set of properties and represent these properties as a bits in a bitmask. This is the key for effective work with tilemaps procedurally.

Комментариев нет:

Отправить комментарий