• Tutorials
  • [Written Tutorial - Really Long Thread] Simple Dungeons-like Grid Placement System - Not Perfect

##Why I am sharing this Hi, I have decided, after being done trying to fix or improve my code on this little project, that I would share the way I did it in the hope that it could help someone in godot's community a little bit.

##Why am I sharing it like so I personally miss the older tutorials that were all written in text where even if some had videos the thread itself was complete and explained everything in understandable details. So I have decided to make this a written tutorial. If requested, I will make a video of it and add it to this thread in the future.

##Who helped me on this I would like to start off by thanking these amazing members of the community for helping me out on the questions I asked related to this project here on the forums: - cybereality - Helped me out a ton with making me understand that I didn't need multiple arrays and getting the mouse's world position. - xyz - Explained to me how to modify materials and sub-materials

##Setting up the scenes First off, please note that you can modify everything here to fit your scene hierarchy as needed. This is the setup I was using because I did not need anything else since I didn't plan on creating a full game with this. I am simply used to making my hierarchy this way.

##All the steps: Create a Spatial Node in your scene named Game and a Second Spatial Node named Map with Game as its parent. Then create another Spatial Node child to Map named UndergroundBase.

###Game: I was planning on making a UI at the time I made this project, but ended up simply doing this system instead for the time being, so the reason why Game is there in the first place was so that I can have scripts running the whole game.

###Map: This is where I deal with the grid stuff with a script. Create a script on Map and name it Map. (I know I'm really good with script names :) )

###UndergroundBase: This is where I store the tiles simply to keep everything a little more clean.

After you have created these nodes, create a new scene named PlayerCharacter. In this scene, create a KinematicBody node named PlayerCharacter, a Spatial Node named Pivot child of PlayerCharacter, a Camera Node child of Pivot, and a Collision Shape child of PlayerCharacter. Like so:

###PlayerCharacter: Keep everything default on the KinematicBody. We simply use this to move around. Add a script onto it called PlayerController. We will come back to the scripting in the next section.

####Pivot: Keep it on all default values.

####Camera: Set the camera's Transform to: (Feel free to adjust to your preference.

Make sure the camera's projection is set to Perspective.

####CollisionShape: Set the shape to SphereShape. The rest can stay default.

This is it for the setting up. Feel free to adjust the PlayerCharacter's position to your liking as well as any values you see fit. I am simply sharing it the way I did it.

##Scripting: If it is not already done, create these scripts: Map, PlayerController, and Tile. For this section, we will go from the easiest to the biggest script simply because two of the scripts are relatively simple to set up and will not require that much explaining.

###Tile: This script is simply to hold information on the tile itself that we will use later on in the Map script. As such, we export every variable in this script because it does not matter one bit if they are visible to everyone or not due to their specific use.

No need to add this manually anywhere. We add it through code. ####Code:

extends Node

# Variable to be used in the future if you want to build another tileId on the same tile.
export var used = 1

# The hexadecimal colors of the tile itself and its walls.
export var color = "#cccccc"
export var wallColor = "#777777"

# The tile position in world coordinates.
export var gridX = 0
export var gridY = 0

# Where we save the tileId for use in the future if needed.
export var tileId = -1

# Variables that we set when a direction has a wall.
export var north = 0
export var south = 0
export var east = 0
export var west = 0

# These will be holding the wall nodes themselves. We need a default to export, so I set it to 0.
export var northWall = 0
export var southWall = 0
export var eastWall = 0
export var westWall = 0

###PlayerController: This part is script is slightly longer, but still relatively simple. The big part to understand here is that we create a raycast from the mouse position towards the world position of the mouse and detect if there was a collision. (The collisions was required in my testing and it was plenty good enough for the system I wanted to make.)

Make sure this is added to the PlayerCharacter Node

Some methods will be sending errors, do not worry about them for now. They should go away when Map is coded in. ####Code:

extends KinematicBody

onready var player = $Pivot
onready var camera = $Pivot/Camera

var rotationSpeed = 0.002
var movementSpeed = 2
var buildSelection = 3

onready var parent = self.get_parent()

func build(mousePosition):
	# We keep a margin around the grid. This isn't necessary anymore.
	# But I keep it since I wanted to keep a style similar to other RTS games.
	var gridSizeY = parent.tileGrid.size()-2
	var gridSizeX = parent.tileGrid[0].size()-2

	# Holds the world position of the mouse.
	var mouseGameX = round(mousePosition.x+parent.mapOffsetY)
	var mouseGameY = round(mousePosition.z+parent.mapOffsetY)

	# TODO: Maybe fix. So far everything seems to work, but we never know.
	var gridMinX = -gridSizeX/2
	var gridMinY = -gridSizeY/2

	# If the mouse is in the build area, send the updateFloor function.
	if mouseGameX < gridSizeX and mouseGameY < gridSizeY:
		if mouseGameX > gridMinX and mouseGameY > gridMinY:
			parent.updateFloors(mouseGameX, mouseGameY, buildSelection)

func delete(mousePosition):
	# We keep a margin around the grid. This isn't necessary anymore.
	# But I keep it since I wanted to keep a style similar to other RTS games.
	var gridSizeY = parent.tileGrid.size()-2
	var gridSizeX = parent.tileGrid[0].size()-2

	# Holds the world position of the mouse.
	var mouseGameX = round(mousePosition.x+parent.mapOffsetY)
	var mouseGameY = round(mousePosition.z+parent.mapOffsetY)

	# TODO: Maybe fix. So far everything seems to work, but we never know.
	var gridMinX = -gridSizeX/2
	var gridMinY = -gridSizeY/2

	# If the mouse is in the build area, send the deleteFloor function.
	if mouseGameX < gridSizeX and mouseGameY < gridSizeY:
		if mouseGameX > gridMinX and mouseGameY > gridMinY:
			parent.deleteFloors(mouseGameX,mouseGameY)

# Transforms the mouse's screen position to world position.
func get_world_pos(mouse_pos):
	var ray_length = 1000
	var from = camera.project_ray_origin(mouse_pos)
	var to = from + camera.project_ray_normal(mouse_pos) * ray_length
	var space_state = get_world().get_direct_space_state()
	var result = space_state.intersect_ray(from, to)
	var world_pos = Vector3()
	if result.has("position"):
		world_pos = result.get("position")
	return world_pos

func _unhandled_input(event):
	# On left mouse click
	if event is InputEventMouseButton and event.pressed and event.button_index == BUTTON_LEFT:
		#print(get_world_pos(event.position))
		build(get_world_pos(event.position))
	# On left mouse click
	if event is InputEventMouseButton and event.pressed and event.button_index == BUTTON_RIGHT:
		#print(get_world_pos(event.position))
		delete(get_world_pos(event.position))


# Called every frame. 'delta' is the elapsed time since the previous frame.
func _physics_process(_delta):
	var direction = Vector3()
	var velocity = Vector3()
	var target_velocity = Vector3()

	# Sets the player's movement direction relative to its position.
	if Input.is_action_pressed("up"):
		direction += -global_transform.basis.z
	if Input.is_action_pressed("down"):
		direction += global_transform.basis.z
	if Input.is_action_pressed("left"):
		direction += -global_transform.basis.x
	if Input.is_action_pressed("right"):
		direction += global_transform.basis.x
	if Input.is_action_pressed("shift"):
		movementSpeed = 4
	else:
		movementSpeed = 2

	# Normalizes the direction.
	direction = direction.normalized()

	# We accelerate the direction to the movementSpeed so we can move at more than 1 unit.
	target_velocity = direction * movementSpeed
	velocity.x = target_velocity.x
	velocity.z = target_velocity.z

	# We move.
	velocity = move_and_slide(velocity, Vector3.UP, true)

Which now brings us to the last and biggest piece. Hope you are ready for some typing.

###Map: This part is the biggest and probably the messiest. I have tried to make it as optimized as I could think of and as simple to read as I could, but this is far from perfect. This is still good enough to create a 2000x2000 map in about 10 seconds with no residual lag afterwards.

It is super important that the walls do not have collision. Or at least this was the only way I found to make it work without the tiles being created at the wrong position when clicking on a wall.

Make sure this script is added to the Map node.

####Code:

extends Spatial

# Map variables
export var mapWidth = 20
export var mapHeight = 20
var mapOffsetX
var mapOffsetY

onready var undergroundBase = find_node("UndergroundBase")

# grid holding the tiles themselves
var tileGrid = []

func deleteFloors(posX, posY):
	if tileGrid[posY][posX]:
		undergroundBase.remove_child(tileGrid[posY][posX])
		tileGrid[posY][posX] = null
		updateWalls(posX, posY)

func updateFloors(posX, posY, tileId):
	if tileGrid[posY][posX]:
		undergroundBase.remove_child(tileGrid[posY][posX])

	# create a tile at the location of the mouse after deleting the old one.
	if tileId == 0:
		tileGrid[posY][posX] = createFloor(posX, posY, tileId)
	if tileId == 1:
		tileGrid[posY][posX] = createFloor(posX, posY, tileId, "#b3d733", "#4b5039")
	if tileId == 2:
		tileGrid[posY][posX] = createFloor(posX, posY, tileId, "#3dbfbf", "#395050")
	if tileId == 3:
		tileGrid[posY][posX] = createFloor(posX, posY, tileId,"#f300f8", "#7c327e")
	updateWalls(posX, posY)

func updateWalls(mouseX, mouseY):
	var tile = tileGrid[mouseY][mouseX]

	# Checks if the tile at the north has a wall or not.
	if tileGrid[mouseY-1][mouseX]:
		var temp = tileGrid[mouseY-1][mouseX]
		# If there is one and we are placing a tile, remove it.
		if temp.south == 1 and tile:
			temp.remove_child(temp.southWall)
			temp.south = 0
		# If there is no tile at our current positon, add a wall.
		if temp.south == 0 and !tile:
			temp.southWall = createWall(temp, 0, 0.5, 0, -90)
			temp.south = 1
	else:
		# If there is a tile at our position, and there is none north of us, add a wall.
		if tile:
			tile.north = 1

	# Checks if the tile at the south has a wall or not.
	if tileGrid[mouseY+1][mouseX]:
		var temp = tileGrid[mouseY+1][mouseX]
		# If there is one and we are placing a tile, remove it.
		if temp.north == 1 and tile:
			temp.remove_child(temp.northWall)
			temp.north = 0
		# If there is no tile at our current positon, add a wall.
		elif temp.north == 0 and !tile:
			temp.northWall = createWall(temp, 0, -0.5, 0, 90)
			temp.north = 1
	else:
		# If there is a tile at our position, and there is none south of us, add a wall.
		if tile:
			tile.south = 1

	# Checks if the tile at the east has a wall or not.
	if tileGrid[mouseY][mouseX+1]:
		var temp = tileGrid[mouseY][mouseX+1]
		# If there is one and we are placing a tile, remove it.
		if temp.west == 1 and tile:
			temp.remove_child(temp.westWall)
			temp.west = 0
		# If there is no tile at our current positon, add a wall.
		elif temp.west == 0 and !tile:
			temp.westWall = createWall(temp, -0.5, 0, -90, 0)
			temp.west = 1
	else:
		# If there is a tile at our position, and there is none east of us, add a wall.
		if tile:
			tile.east = 1

	# Checks if the tile at the west has a wall or not.
	if tileGrid[mouseY][mouseX-1]:
		var temp = tileGrid[mouseY][mouseX-1]
		# If there is one and we are placing a tile, remove it.
		if temp.east == 1 and tile:
			temp.remove_child(temp.eastWall)
			temp.east = 0
		# If there is no tile at our current positon, add a wall.
		elif temp.east == 0 and !tile:
			temp.eastWall = createWall(temp, 0.5, 0, 90, 0)
			temp.east = 1
	else:
		# If there is a tile at our position, and there is none west of us, add a wall.
		if tile:
			tile.west = 1

	# If there is a tile and the desired wall direction is active, create a child wall
	# at that position and rotation.
	if tile:
		# North
		if tile.north == 1:
			tile.northWall = createWall(tile, 0, -0.5, 0, 90)
		# South
		if tile.south == 1:
			tile.southWall = createWall(tile, 0, 0.5, 0, -90)
		# East
		if tile.east == 1:
			tile.eastWall = createWall(tile, 0.5, 0, 90, 0)
		# West
		if tile.west == 1:
			tile.westWall = createWall(tile, -0.5, 0, -90, 0)

Had to seperate this into multiple posts, sorry about that.

Rest of the code for Map: (I have cut the previous post right after the updateWall function, so you can simply start a line right after it without any problem with tabulations.)

# Creates a floor tile at the desired position with the desired color.
func createFloor(x, y, tileId, color = "#000000", wallColor = "#000000"):
	var tileScript = preload("res://Scripts/Tile.gd")
	var plane = StaticBody.new()

	# We add the tile script to the floor plane and set its values.
	plane.set_script(tileScript)
	plane.tileId = tileId
	plane.used = 1

	# If no colors are input, they will be defaulted to black.
	plane.color = color
	plane.wallColor = wallColor

	# We create a new material with the first color.
	var material = SpatialMaterial.new()
	material.albedo_color = plane.color

	# We create the tile's mesh.
	var mesh = MeshInstance.new()
	mesh.scale = Vector3(0.5, 0.5, 0.5)
	mesh.mesh = PlaneMesh.new()
	mesh.set_surface_material(0,material)

	# We create a collision for the mesh so the raycast from the player can collide.
	var collision = CollisionShape.new()
	collision.shape = PlaneShape.new()
	collision.scale = Vector3(0.05, 0.05, 0.05)

	# We add everything together, name it Floor, and add it to the group Floors.
	plane.add_child(mesh)
	plane.add_child(collision)
	plane.name = "Floor"
	plane.add_to_group("Floors")
	# We set its position to what was sent through the method's arguments.
	plane.transform.origin = Vector3(x, 0, y)

	undergroundBase.add_child(plane)
	plane.gridX = x
	plane.gridY = y

	# We return the tile so we can store it in a grid.
	return plane

# Creates a wall at the desired position with the position's tile being its parent.
func createWall(parent, x, y, rotateX = 0, rotateY = 0):

	# We get the radiant of the rotation we want. (This way we can write 90 for simplicity.)
	rotateX = deg2rad(rotateX)
	rotateY = deg2rad(rotateY)

	# Same as the floor tile, we create a plane.
	var plane = StaticBody.new()

	# Now we create a material with the tile's wall color.
	var material = SpatialMaterial.new()
	material.albedo_color = parent.wallColor

	# Same as before, we make a mesh and set its material.
	var mesh = MeshInstance.new()
	mesh.scale = Vector3(0.5, 0.5, 0.5)
	mesh.mesh = PlaneMesh.new()
	mesh.set_surface_material(0, material)

	#TODO: Collision not needed on wall, simply create a path for the ai on the
	# spaces that has a collision.

	# We once again add everything together same as the floor tile.
	plane.add_child(mesh)
	plane.name = "Wall"
	plane.add_to_group("Walls")
	plane.transform.origin = Vector3(x, 0.5, y)
	plane.rotation = Vector3(rotateY, 0, rotateX)

	# This time we set this wall's parent to the tile at this position.
	parent.add_child(plane)
	return plane

# Called when the node enters the scene tree for the first time.
func _ready():
	# Calculating the map's center.
	mapOffsetX = (mapWidth + 2)/2
	mapOffsetY = (mapHeight + 2)/2

	# Offsetting the undergroundBase node so that the map appears at the right location.
	# We could do this to the Map node instead, but this allows having the overworld and
	# the underground on the same scene.
	undergroundBase.global_transform.origin.x -= mapOffsetX
	undergroundBase.global_transform.origin.z -= mapOffsetY

	# We initialize the tiles grid.
	for y in range(mapHeight + 2):
		tileGrid.append([])
		tileGrid[y] = []
		for x in range(mapWidth + 2):
			tileGrid[y].append([])
			tileGrid[y][x] = null

	# Test platform to debug with. Feel free to play with it or remove as you want.
	var testSizeX = 10
	var testSizeY = 10
	for y in range(testSizeX):
		for x in range(testSizeX):
			updateFloors(x-1+mapOffsetX-testSizeX/2, y-1+mapOffsetY-testSizeY/2, 1)

	for y in range(testSizeX):
		for x in range(testSizeX/2):
			updateFloors(x-1+mapOffsetX-testSizeX/2, y-1+mapOffsetY-testSizeY/2, 2)

##Inputs: Make sure to add these input in your project's input settings. Feel free to use the ones you want. I added every input because originally I planned on making this work for all input types, but I really just wanted to make a system similar to Dungeons initially and ended up not implementing it all the way. Although, the only thing left to add really is the mouse clicks and movements for the controllers.

##Trying it out: Your main scene should now look like this: And if you hit play, you should now be seeing this and be able to click and move around. (WASD Movements, LEFT MOUSE CLICK to add a tile, RIGHT MOUSE CLICK to delete a tile.)

##Final Words: Feel free to modify the code so that you can use models instead of creating one from scratch through code. I just wanted to make quick debug shapes until it worked.

Hopefully this helped you in some way and brought some new progression in your quest :) I am super thankful to everyone who helped me on this little project even if it was a few posts here and there. They deserve all the credit since I would not have been able to complete this without t heir help.

On that note, I am wishing you and yours the absolute best and a wonderful day!

Ps. If there is anything in here that you would like changed or explained in a little more detail, feel free to ask away and I will do my absolute best to reply with a good answer.

Sorry for the mega posts. Apparently making a written tutorial takes a lot more place than I thought haha. Apparently the original post I wanted to send was about 10k characters too high.