Generating Tile Images IV (GameWorld 13)

Strap up for a Rocky Ride! This post is a follow-up from GameWorld 12, where we looked at dithering and colour noise algorithms for our 2D map tiles.

Today, we're going for the next step, and generate whole rock formations without manually drawing a single pixel! Because we are getting more complex now, I will link to individual repl.it files throughout the blog post, in addition to just giving the link to the whole program at the end. I think that will make it easier for you to track down the relevant bits of code :).

But yes, to get there, we need a whole load of steps to build up. So, time to get started!

Preparation: Matrix data structure and a random pixel picker

In general, to go to this more advanced level, we need two new tools. First, we need a simpler way to generate shapes, so instead of using color values, I will use a simple matrix of 0 and 1 values to develop the shapes of the stones.

The matrix has a size of 48 by 48, essentially the same dimensions as the pixels in the tile.

I put all the matrix-related code in matrix.py, and a new matrix is basically made like this:

def new():

return np.zeros((48,48), dtype=int)

Now, to grow stones, I want to be able to randomly pick pixels in a tile, but in a way such that I don't pick the same pixel twice when I use the algorithm 48x48 times. To do that, I made a pixel picker which works in two steps:

  1. It generates a list of all the pixel coordinates in the tile. So from (0,0) to (0,47), then (1,0) to (1,47) etc. until we reach (47,47).
  2. It then creates a second list, which is made by taking out randomly chosen elements from the first list. The second list therefore is a random shuffle from the first list.

In code, this looks like this in tile_patterns.py:

def random_pixels():

# build a list with all the pixel coordinates.

pixels = []

for x in range(48):

for y in range(48):

pixels.append((x, y))

# randomly take elements from the first list to build a second one.

pixels2 = []

while len(pixels) > 0:

i = random.randrange(len(pixels))

pixels2.append(pixels[i])

del pixels[i]

print(pixels2)

return pixels2

Growing stones

To grow stones, we first have to place them in different locations (I place 15 in a tile), and after that we will choose random pixels to grow out from. I do this as follows:

def make_stones():

tile = matrix.new()

pixels = random_pixels()

for i in range(15):

tile[pixels[i][0],pixels[i][1]] = i+1

for i in range(100000):

x = random.randrange(48)

y = random.randrange(48)

grow(tile, x, y)

tile = matrix.make_binary(tile)

return tile

The 100,000 value means that I will choose random pixels 100,000 times to let the stones grow. Here a smaller value may lead to bigger cracks, while a bigger value takes rather long in terms of calculation time.

Now, to grow the stones we do the following:

  1. we pick a pixel.
  2. if the pixel is a “stone”, then we pick a random neighbour.
  3. if that neighbour is not a stone, and has neighbours that are either not a stone, or part of the stone found in step 2, then we turn that neighbour into a stone.

In code, this looks like this:

def grow(tile, x, y):

neigh = get_neighbour_list(x,y)

val = tile[x,y]

if val > 0:

i = random.randrange(8)

if tile[neigh[i][0], neigh[i][1]] == 0:

if not has_neighbour(tile, neigh[i][0], neigh[i][1], val):

tile[neigh[i][0], neigh[i][1]] = val

return True

return False

The condition in step 3 is placed in the has_neighbour() function, which looks like this:

def has_neighbour(tile, x, y, mask_value):

if tile[x,y] == 1:

return False

neigh = get_neighbour_list(x,y)

count = 0

for a in neigh:

if tile[a[0],a[1]]>0 and tile[a[0],a[1]] != mask_value:

count += 1

if count > 0:

return True

return False

All this is enough to generate a stone tile roughly like this:

This looks okay, but we lack a bit of depth...

Adding shades and highlights

To add shading, I developed a simple algorithm. Essentially it crawls pixel by pixel from bottle to top, and adds a shade pixel as soon as it exits one of the cracks. In code it looks like this:

def bottom_edge(matrix):

shades = new()

for x in range(matrix.shape[0]):

shade = False

for y in range(matrix.shape[1]+1):

y2 = (matrix.shape[1]-y)%48

if matrix[x,y2] == 0:

shade = True

continue

elif shade == True and matrix[x,y2] == 1:

shades[x,y2] = 1

shade = False

return shades

I also use a variation of this, crawling from right to left, to add shading to the right side.

And to do highlighting, I use two more variations, one from left to right to add highlights on the left side, and one from top to bottom to add highlights to the top side.

You can see the code for all four of them here in matrix.py :).

By using these “pixel crawlers”, our tile will then look like this:

I think this gives enough depth for floor tiles, but perhaps not so much for rounded mountain rocks (that problem I still have to solve(!)).

Adding “cliff” effects

To add rudimentary cliff effects, we essentially need a filter that makes a rock wall a bit darker at the bottom, and a bit lighter at the top. To do this, I defined two functions called drop_shadow() and top_highlight(). Here's what drop_shadow() looks like:

def drop_shadow(t, i, intensity=0.5):

d,x,y,tw,th = t.get_properties(i)

th2 = int(th/2)

for yi in range(th2, th+1):

for xi in range(0, tw+1):

gradient = (yi-th2) / (th-th2)

color = shade(tp.get_rgb(t,i,xi,yi), intensity*gradient)

draw.pixel(t, i, color, xi, yi)

It essentially loops, row by row, over the pixels in the bottom half of a tile, and darkens the color of each pixel. The gradient is a variable that starts at 0.0 halfway down the tile, and gradually increases to 1.0 at the very bottom of the tile. In this way, each row of pixels in the bottom half gets darkened, and to a greater extent if those rows are closer to the bottom. When we apply this filter to our rocky road, this is what it looks like:

And here's what top_highlight() looks like in code:

def top_highlight(t, i, intensity=0.5):

d,x,y,tw,th = t.get_properties(i)

th2 = int(th/2)

for yi in range(0, th2+1):

for xi in range(0, tw+1):

gradient = 1.0 - (yi / th2)

color = highlight(tp.get_rgb(t,i,xi,yi), intensity*gradient)

draw.pixel(t, i, color, xi, yi)

This is almost the same as drop_shadow(), but instead it works on the top half of the tile, and adds more highlighting towards the top border. As a result, we get something that looks like this:

And when we combine all three, our rudimentary cliff looks as follows:

It is an interesting result, because while it does look rocky, it does seem that we somehow need to add more depth to the individual rocks to get a true cliff-like effect going :).

Closing thoughts

Well, that must have been a rocky ride for most of you, and honestly it took me quite a long time to get this right. But at least we now have a basic form of rocks without having to manually draw then :).

One thing that I didn't mention is that good tiles have to connect with each other correctly at the edges. With this algorithm that will be the case, but only if you connect the exact same rock pattern to each other in multiple tiles.

I may be able to solve that problem someday, but that one might end up becoming a little bit more compute-intensive ;).

Instead, for this series, I will probably look at entirely different tile types in the next instalment, or even cover a different topic altogether.

Lastly, you can find the full repl.it for this tutorial here. Instead of making new ones for every blog post on tiles, I will be enhancing this version though, so that this link will always link to the newest tile generator.

Continue reading with a Coil membership.