A pretty flexible way to put tiles on a grid - the Recycling devlog #2


Hello! In today's devlog I'm going to talk about how I added a simple grid based factory system to my game, the Recycling. This also includes some more advanced features, like diffrent form factors for the tiles.

If you think that my approach could be improved upon, feel free to leave a comment with some advice, so I can make my game even better.

Also, keep in mind that all the code I will present in this devlog is in GDScript and was written in the 3.x version of godot, although I believe it could easily be translated in other languages and used in other engines.

Without further ado, let's begin.

A deceptively blank canvas

So, when you first start the game, the only things present in the building site are little columns with trash floating above them. The grid is currently blank, and is waiting for you to start placing some machinery to recycle and profit.

Or is it?

(queue Vsauce music)

To answer this question, we must take a look at how the tile manager script works.

#variables
var tileSize := Vector2(3, 3)
var gridSize := Vector2(15, 8)
var tiles := []
    
func initialize_tiles():
    var nTiles := []
    #expand the grid
    gridSize += Vector2(2, 2)
    
    #initialize tiles
    for yI in gridSize.y:
        var curRow := []
        for xI in gridSize.x:
            #add new tile holder
            var nTile = preload("res://Tiles/TileHolder.tscn").instance()
            add_child(nTile)
            var offset = Vector2(xI - 1, yI - 1) * tileSize
            nTile.translation = Vector3(offset.x, 0, offset.y)
            nTile.pos = Vector2(xI, yI)
            
            #add new tile entry
            curRow.append({"index" : 0, "rotation" : 0})
        nTiles.append(curRow)
    
    #apply changes to tile register
    tiles = nTiles

The initialize_tiles function is ran from the game manager once the scene is loaded, but if you don't have a game manager, calling it in _ready will also run it as soon as the node loads.

The way the tile manager keeps track of the grid is by having an array (called "tiles") filled with various other arrays which rappresent the rows of the grid, which in turn contain a bunch of dictionaries with information about which tile and in what orientation is in that spot.

The function simply generates one of those dictionaries for every tile, with an index of 0, meaning it's empty. It also adds a tile holder node as a child of the tile manager node, which has a variable (pos) that specifies its placement on the tile grid, a hitbox for the player to find it and a mesh to use as a preview of the currently selected structure. These are spaced out according to the tileSize variable.

So, you may have noticed that at the beginning of the function, the grid is expanded by two units. This is because to determine which tile the player wants to build on, the game first looks at the position of the tile the player is standing on, and then adds to that the direction they're looking at. So if the player is looking at the border of the grid from outside, they aren't actually standing on any tile and therefore can't build. The player isn't actually allowed to build on this outside layer, it just allows them to build on the edges of the building side.

I hope that made sense.

So, now that we have our canvas made from tile holders, we need to add some actual tiles.

Prepare for construction

To instantiate structures we first need two things.

  1. Knowing what we are instantiating and in which direction;
  2. A reference to all the tiles we have at our disposal.

The first one we actually already took care of, with the tiles array that we filled in the previous step. Yet, if this isn't an empty save file, we must make sure to override the tiles array right after the initialize_tiles function with the copy of the array stored in the save file. The function still needs to run in order to instantiate tile holders, though.

After that, we need references to the tile nodes. Now, in the jam version, I simply used an array of paths that I then loaded into another array to hold references, but that doesn't scale very well, especially when each tile has a price, a name and a description associated with it, so I recently reworked it to use custom resources in godot.

The system is composed of two resources: one for the single tiles and one for sections of tiles.

extends Resource
class_name TileResource
    
#path of the tile node
export(String, FILE) var tilePath = ""
    
#path of the tile's preview model
export(String, FILE) var previewPath = ""
    
#icon, name and description;
#The arrays allow for translation to multiple languages
export(Texture) var icon
export(Array, String) var tileNames = ["",""]
export(Array, String, MULTILINE) var descriptions = ["",""]
    
#the price of each tile
export var cost := 0
    
#if a tile occupies more than one spot on the grid,
#these booleans tell us which other spots it occupies
export var xNegative := false
export var yNegative := false
export var xPositive := false
export var yPositive := false
    
export var frontFacing := false
extends Resource
class_name TilesSection
export var sectionName := ""
export(Array, Resource) var tileResources = []

The tile sections are really just to break up the structure selection menu in multiple sections for the player's sake, because from the perspective of the tile manager the tiles all exist in a continuos array. 

A reference to each tile's node is then loaded and cached in the _ready function of the tile manager script, together with the models used to preview each tile and the tile's cost. The tile resources also get put into a single array, which will be useful later.

var tileResources := []
var tileRefs := []
var tileModels := []
var tilePrices := []
export(Array, Resource) var tileSections = []
    
func _ready():
    #add empty base tile
    tilePrices.append(0)
    tileResources.append(null)
    tileRefs.append(preload("res://Tiles/Tile.tscn"))
    tileModels.append(null)
    
    #turn tile sections into one big array with all the tile resources
    for s in tileSections:
        for t in s.tileResources:
            tileResources.append(t)
    
    #load each tile resource's parameters
    for t in tileResources:
        if(t != null):
            tileRefs.append(load(t.tilePath))
            tileModels.append(load(t.previewPath))
            tilePrices.append(t.cost)

Bring in the structures, bois!

Ok, so at this point we have:

  • An array containing all the information about the tiles on the grid;
  • A bunch of tile holders instantiated as children of our main tile manager;
  • A bunch of references to the tiles we need to instantiate.

All is ready to start instantiating the actual tiles, which we will do in another function:

func instance_all_tiles():
    #For every tile holder
    for c in get_children():
        #empty the tile holder in case it wasn't already
        for sc in c.get_child(0).get_children():       
            sc.queue_free()
        
        var nTile = null
        
        #if the current tile is a full structure
        if(tiles[c.pos.y][c.pos.x].index >= 0):
            nTile = tileRefs[tiles[c.pos.y][c.pos.x].index].instance()
        
        #if the current tile isn't empty;
        #this will always be the case on an empty save file,
        #but may not be if we were to load a save.
        if(nTile != null):
            #instantiate the tile and rotate it accordingly
            c.get_child(0).add_child(nTile)
            nTile.rotation_degrees = Vector3(0, 90 * tiles[c.pos.y][c.pos.x].rotation, 0)
            nTile.translation = Vector3.ZERO

Now that we have instantiated all the tiles on the grid, we want to be able to edit them one by one without re-instantiating the wole grid every time (which isn't exactly performant).

To do that, we need three more functions:

  1. A function to easily check the index of a single tile;
  2. A function to check if a given tile will fit on a spot(don't worry, this will make sense in just a sec);
  3. A function to actually change the tile, and possibly change the player's money ammount according to the price of the tile.

Which is to say:

func check_pos(var pos : Vector2):
    var check = null
    
    if(pos.x >= 1 && pos.x < gridSize.x && pos.y >= 1 && pos.y < gridSize.y):
        check = tiles[pos.y][pos.x].index
    
    #if this is null, the tile is already occupied or
    #is one of the extra border tiles we added earlier.
    return check

And also:

func check_tile(var pos : Vector2, var id : int, var rot : int):
    #if the provided index is invalid
    if(id <= 0 || id >= tileResources.size()):
         return false
    
    var t = tileResources[id]
    
    #Check if any extra spots needed for this tile
    #are blocked, in accordance with the tile's rotation.
    match rot:
        0:
            if(t.xNegative && check_pos(Vector2(pos.x - 1, pos.y)) != 0):
                return false
            if(t.yNegative && check_pos(Vector2(pos.x, pos.y - 1)) != 0):
                return false
            if(t.xPositive && check_pos(Vector2(pos.x + 1, pos.y)) != 0):
                return false
            if(t.yPositive && check_pos(Vector2(pos.x, pos.y + 1)) != 0):
                return false
        1:
            if(t.xNegative && check_pos(Vector2(pos.x, pos.y + 1)) != 0):
                return false
            if(t.yNegative && check_pos(Vector2(pos.x - 1, pos.y)) != 0):
                return false
            if(t.xPositive && check_pos(Vector2(pos.x, pos.y - 1)) != 0):
                return false
            if(t.yPositive && check_pos(Vector2(pos.x + 1, pos.y)) != 0):
                return false
        2:
            if(t.xNegative && check_pos(Vector2(pos.x + 1, pos.y)) != 0):
                return false
            if(t.yNegative && check_pos(Vector2(pos.x, pos.y + 1)) != 0):
                return false
            if(t.xPositive && check_pos(Vector2(pos.x - 1, pos.y)) != 0):
                return false
            if(t.yPositive && check_pos(Vector2(pos.x, pos.y - 1)) != 0):
                return false
        3:
            if(t.xNegative && check_pos(Vector2(pos.x, pos.y - 1)) != 0):
                return false
            if(t.yNegative && check_pos(Vector2(pos.x + 1, pos.y)) != 0):
                return false
            if(t.xPositive && check_pos(Vector2(pos.x, pos.y + 1)) != 0):
                return false
            if(t.yPositive && check_pos(Vector2(pos.x - 1, pos.y)) != 0):
                return false
    
    #checks the central spot of the tile
    if(check_pos(pos) != 0):
        return false
    
    #if the function hasn't returned yet,
    #it means we can go ahead and place the tile!
    return true

This one might seem a little daunting at first glance, but all it really does is check for every spot occupied by the tile, not only the main one but also any other extra spots that might be specified in the tile resource; and since those are specified in relation to rotation 0, we need to rotate them accordingly: for example, the xNegative spot is the one on the left in rotation 0, but becomes the one on the top when the tile is rotated 90° clockwise in rotation 1.

Lastly, we need the meat of this section, which is the change_tile function:

func change_tile(var pos : Vector2, var naTile, var nRot):
    if(naTile != 0 && tileResources[naTile].frontFacing):
        nRot = 0
    
    #an array containing any extra spots the tile might occupy
    var extTiles := []
    
    if(plr.money < tilePrices[naTile]):
        return false
    
    #check that there is room for this tile
    var canPlace = true
    if(naTile != 0):
        canPlace = check_tile(pos, naTile, nRot)
    
    if(canPlace):
        var ret = false
        var t = tileResources[naTile]
        var tIndex = naTile
        var tRot = nRot
        
        #if we're changing the tile to 0, therefore removing it
        if(naTile == 0):
            #grabs the parameters from whatever tile the
            #pos variable points to, before we change anything
            tIndex = tiles[pos.y][pos.x].index
            tRot = tiles[pos.y][pos.x].rotation
            t = tileResources[abs(tIndex)]
        
        if(tIndex < 0):
            #if this isn't the main tile, find the main tile
            #and change the pos variable to point to it
            match tRot:
                0:
                    if(t.xNegative && check_pos(Vector2(pos.x + 1, pos.y)) == -tIndex && tiles[pos.y][pos.x].rotation == tRot):
                        pos = Vector2(pos.x + 1, pos.y)
                    elif(t.yNegative && check_pos(Vector2(pos.x, pos.y + 1)) == -tIndex && tiles[pos.y][pos.x].rotation == tRot):
                        pos = Vector2(pos.x, pos.y + 1)
                    elif(t.xPositive && check_pos(Vector2(pos.x - 1, pos.y)) == -tIndex && tiles[pos.y][pos.x].rotation == tRot):
                        pos = Vector2(pos.x - 1, pos.y)
                    elif(t.yPositive && check_pos(Vector2(pos.x, pos.y - 1)) == -tIndex && tiles[pos.y][pos.x].rotation == tRot):
                        pos = Vector2(pos.x, pos.y - 1)
                1:
                    if(t.xNegative && check_pos(Vector2(pos.x, pos.y - 1)) == -tIndex && tiles[pos.y][pos.x].rotation == tRot):
                        pos = Vector2(pos.x, pos.y - 1)
                    elif(t.yNegative && check_pos(Vector2(pos.x + 1, pos.y)) == -tIndex && tiles[pos.y][pos.x].rotation == tRot):
                        pos = Vector2(pos.x + 1, pos.y)
                    elif(t.xPositive && check_pos(Vector2(pos.x, pos.y + 1)) == -tIndex && tiles[pos.y][pos.x].rotation == tRot):
                        pos = Vector2(pos.x, pos.y + 1)
                    elif(t.yPositive && check_pos(Vector2(pos.x - 1, pos.y)) == -tIndex && tiles[pos.y][pos.x].rotation == tRot):
                        pos = Vector2(pos.x - 1, pos.y)
                2:
                    if(t.xNegative && check_pos(Vector2(pos.x - 1, pos.y)) == -tIndex && tiles[pos.y][pos.x].rotation == tRot):
                        pos = Vector2(pos.x - 1, pos.y)
                    elif(t.yNegative && check_pos(Vector2(pos.x, pos.y - 1)) == -tIndex && tiles[pos.y][pos.x].rotation == tRot):
                        pos = Vector2(pos.x, pos.y - 1)
                    elif(t.xPositive && check_pos(Vector2(pos.x + 1, pos.y)) == -tIndex && tiles[pos.y][pos.x].rotation == tRot):
                        pos = Vector2(pos.x + 1, pos.y)
                    elif(t.yPositive && check_pos(Vector2(pos.x, pos.y + 1)) == -tIndex && tiles[pos.y][pos.x].rotation == tRot):
                        pos = Vector2(pos.x, pos.y + 1)
                3:
                    if(t.xNegative && check_pos(Vector2(pos.x, pos.y + 1)) == -tIndex && tiles[pos.y][pos.x].rotation == tRot):
                        pos = Vector2(pos.x, pos.y + 1)
                    elif(t.yNegative && check_pos(Vector2(pos.x - 1, pos.y)) == -tIndex && tiles[pos.y][pos.x].rotation == tRot):
                        pos = Vector2(pos.x - 1, pos.y)
                    elif(t.xPositive && check_pos(Vector2(pos.x, pos.y - 1)) == -tIndex && tiles[pos.y][pos.x].rotation == tRot):
                        pos = Vector2(pos.x, pos.y - 1)
                    elif(t.yPositive && check_pos(Vector2(pos.x + 1, pos.y)) == -tIndex && tiles[pos.y][pos.x].rotation == tRot):
                        pos = Vector2(pos.x + 1, pos.y)
        #if this tile is bigger than 1x1, find all the extra spots
        #and put them in the extTiles array
        match tRot:
            0:
                if(t.xNegative):
                    extTiles.append(tiles[pos.y][pos.x - 1])
                if(t.yNegative):
                    extTiles.append(tiles[pos.y - 1][pos.x])
                if(t.xPositive):
                    extTiles.append(tiles[pos.y][pos.x + 1])
                if(t.yPositive):
                    extTiles.append(tiles[pos.y + 1][pos.x])
            1:
                if(t.xNegative):
                    extTiles.append(tiles[pos.y + 1][pos.x])
                if(t.yNegative):
                    extTiles.append(tiles[pos.y][pos.x - 1])
                if(t.xPositive):
                    extTiles.append(tiles[pos.y - 1][pos.x])
                if(t.yPositive):
                    extTiles.append(tiles[pos.y][pos.x + 1])
            2:
                if(t.xNegative):
                    extTiles.append(tiles[pos.y][pos.x + 1])
                if(t.yNegative):
                    extTiles.append(tiles[pos.y + 1][pos.x])
                if(t.xPositive):
                    extTiles.append(tiles[pos.y][pos.x - 1])
                if(t.yPositive):
                    extTiles.append(tiles[pos.y - 1][pos.x])
            3:
                if(t.xNegative):
                    extTiles.append(tiles[pos.y - 1][pos.x])
                if(t.yNegative):
                    extTiles.append(tiles[pos.y][pos.x + 1])
                if(t.xPositive):
                    extTiles.append(tiles[pos.y + 1][pos.x])
                if(t.yPositive):
                    extTiles.append(tiles[pos.y][pos.x - 1])
        
        #change the player's money ammount depending
        #if you bought or sold a tile
        if(naTile == 0):
            plr.money += tilePrices[tiles[pos.y][pos.x].index]
        else:
            plr.money -= tilePrices[naTile]
            ret = true
        
        #set any extra tiles to the negative of the index,
        #to mark them as occupied
        for i in extTiles:
            i.index = -naTile
            i.rotation = nRot
        
        #set the index and rotation in the tiles array
        tiles[pos.y][pos.x].index = naTile
        tiles[pos.y][pos.x].rotation = nRot
        
        #find the right tile holder, then make sure it doesn't
        #have any other tile inside it
        var c = get_child(pos.x + pos.y * gridSize.x)
        for sc in c.get_child(0).get_children():
            sc.queue_free()
        
        #instantiate the new tile inside the tile holder
        var nTile = tileRefs[naTile].instance()
        c.get_child(0).add_child(nTile)
        nTile.rotation_degrees = Vector3(0, 90 * tiles[c.pos.y][c.pos.x].rotation, 0)
        nTile.translation = Vector3.ZERO
        
        return ret
    else:
        return false

Oof, that's quite a lot of code! Here's a breakdown of what's happening:

  1. We check wether the player can afford this tile;
  2. If this isn't an empty tile (meaning we're removing the current tile), we check wether there is enough room for it;
  3. If we're removing, we make sure that the spot we provided is actually in the middle of  whatever tile might currently be occupying it (since tiles can be bigger than 1x1, that's not always the case) and if it's not we correct it;
  4. We then add to an array any extra spots this tile might occupy, which we then set as the inverse of the index provided for the tile (this way they occupy space, but we know they're not the main tile and the player can't, for example, sell the right and left sides of a tile separetly from the middle);
  5. Set the index and rotation of the tile;
  6. Change the player's money ammount (raise it if we're removing a tile, lower it if we're adding);
  7. Instantiate the actual tile.

And that's pretty much it!

Of course, there would still be all the player interaction and hud stuff to go through, but this post is already really long as it is, so I think I'll have to leave this out. Also, if you copy this code, keep in mind that the tile holder must have a node as its first child to actually put the tile under. This is because, in the actual game, the tile holder also contains a bunch more stuff, so this way I can keep things more organized.

In conclusion

Hey! You made it to the end of the devlog. I hope this post was helpful to you, at least a little. If you enjoyed it, consider leaving a like, and if you think the system could be improved in some way, don't hesitate to write it down in the comments. Help and constructive criticism are always welcome.

Finally, consider following me if you'd like to get notified when I post a devlog, or when the game comes out.

Until next time, goodbye and

Thank you for reading!

ps

There was a bug in the code for the change_tile function, but it should be fixed now. Basically, everything was considered as a one slot tile and rotation wasn't taken into account, which made it impossible to tell if an adjacent spot belonged to the same tile as the extra tile being considered.

Get The Recycling

Leave a comment

Log in with itch.io to leave a comment.