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.
- Knowing what we are instantiating and in which direction;
- 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:
- A function to easily check the index of a single tile;
- A function to check if a given tile will fit on a spot(don't worry, this will make sense in just a sec);
- 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:
- We check wether the player can afford this tile;
- If this isn't an empty tile (meaning we're removing the current tile), we check wether there is enough room for it;
- 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;
- 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);
- Set the index and rotation of the tile;
- Change the player's money ammount (raise it if we're removing a tile, lower it if we're adding);
- 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
The Recycling
Turn trash into profit!
Status | On hold |
Author | Lupus In Fabula |
Genre | Simulation |
Tags | 3D, Cute, recycling, Singleplayer, Third Person, Tycoon |
More posts
- A heaping pile of garbage - the Recycling devlog #1Feb 01, 2022
Leave a comment
Log in with itch.io to leave a comment.