Making a Villager RTS Unit that Collects Resources

RTS game resource collection

Villager is similar to how warrior unit was made. It extends the base unit class. The base Unit class provides fundamental functionality of moving and idle states. The Villager class will add more states on top and override its transition and state handling functions to incorporate additional states.

Creating Villager for RTS Game

Start by creating a folder named “Villager” inside the “Units” folder we created earlier in our previous Warrior and Unit tutorial. In this folder, create a scene identical to Warrior. The only difference is the character 3D model. This is how it looks:

rts game villager unit
I am using monk 3D model as I did not found villager on the internet.

Notice that it has a script attached also. So create & attach “Villager.gd’ script. This script extends base Unit.

Villager Script

In villager script, first we define its possible states. We also define some other variables to keep track of what resource a villager is currently gathering, what he is holding in hands (if any) and so on. Also, I declared raycast3d variable as well which we will later use to detect collision with the resource, or the town center (to detect if we have reached that place).


extends Unit

@export var raycast3d: RayCast3D


enum VillagerState {
	SEARCHING_RESOURCE = 200, GOING_TO_RESOURCE, GATHERING_RESOURCE, RETURNING_RESOURCE
}


# The resource that the villager is currently gathering
var currently_gathering: RTSResource = null
var is_gathering: bool = false

var carrying_amount: int = 0
var carrying_resource_type # RTSResourceTypes enum object


func _ready():
	super()



func _process(delta):
	super(delta)



func _physics_process(delta):
	super(delta)

In above code, we only extended Unit & added no Villager-specific functionality. We want our villager to go to the resource if the resource is clicked & the villager is selected. So it will start gathering it. We will do it now;

func _physics_process(delta):
	# ... PREVIOUS CODE ...
	
	if Input.is_action_just_pressed("left_mouse_button") and self in get_tree().get_nodes_in_group("selected_units"):
		var target = RaycastSystem.get_raycast_hit_object(0b00000000_00000000_00000000_00001000)
		if target:
			if target in get_tree().get_nodes_in_group("resources"):
				currently_gathering = target
				is_gathering = true
		else:
			currently_gathering = null
			is_gathering = false
	
	
	if raycast3d.is_colliding():
		var collider = raycast3d.get_collider()
		if collider:
			print("COLLIDER FOR RAYCAST IT: ", collider.name)

In above code we use our raycast system to detect any resource on the place where mouse was clicked. If there exists a resource, we assign that resource to currently_gathering and is_gathering becomes true.

But, we still haven’t managed the states we declared above for the villager. So lets override the _check_state_transitions and _process_all_states for the villager as well, just like we did for the Warrior:


# @Override
func _check_state_transitions(delta: float):
	super(delta) # give control to base script to handle some states
	if currently_gathering:
		var distance_to_resource = (self.global_position * Vector3(1,0,1)).distance_to(currently_gathering.global_position * Vector3(1,0,1))
		if distance_to_resource > 4:
			if carrying_amount > 0:
				current_state = VillagerState.RETURNING_RESOURCE
			else:
				current_state = VillagerState.GOING_TO_RESOURCE
		else:
			if carrying_amount < currently_gathering.gather_rate:
				current_state = VillagerState.GATHERING_RESOURCE
			else:
				current_state = VillagerState.RETURNING_RESOURCE
	else:
		if is_gathering:
			current_state = VillagerState.SEARCHING_RESOURCE
		else:
			pass




# @Override
func _process_all_states(delta: float):
	super(delta)
	_searching_resource(delta)
	_going_to_resource(delta)
	_gathering_resource(delta)
	_returning_resource(delta)

Above code assigns states to current_state based on different conditions. if unit are carrying some amount and is away from the resource, it is returning it. If it is touching the resource & still haven’t collected the full amount, then it is gathering. If it has gathered all the resource, then it is returning it back tot he stockpile (town center). The above logic is confusing as it not human-friendly to think that way, so we have to take some time determining that logic.

In _process_all_states, we called some functions, each handling a separate state (handing what to do in that particular state). Lets define those functions:

func _searching_resource(delta: float):
  # Searching refers to finding new resource when
  # existing has become depleted.
	pass # Searching is not implemented. I leave it upto the reader.


func _going_to_resource(delta: float):
	if current_state == VillagerState.GOING_TO_RESOURCE:
		if current_agent_path_index < current_agent_path.size():
			var target_pos = current_agent_path[current_agent_path_index]
			target_pos.y = self.global_position.y # DO NOT TOUCH IT
			var direction = (target_pos - self.global_position).normalized()
			var distance = self.global_position.distance_to(target_pos)

			# Update velocity for move_and_slide
			velocity.x = direction.x * speed
			velocity.z = direction.z * speed

			# Play walk animation
			animation_player.play("Walk")
			# Rotate towards the target only along y-axis
			look_at(target_pos, Vector3.UP)

			# Check if agent reached the current target position
			if distance < 0.5:
				current_agent_path_index += 1  # Move to the next waypoint
				print("Reached waypoint:", current_agent_path_index)
		else:
			pass

Again, see how _going_to_resource is a repetition of MOVING state in base Unit. This is a bad practice, so I again advice the reader to call the (modified version) of base _process_moving_state of the base Unit class. The overall goal is to define our logic once only. Hence we should not repeat ourselves. – The reason why I kept it that way is to let you know of the possible mistakes that will later lead to difficulty. Also, I figured it out later in the implementation so I didn’t want to refactor the entire codebase.

Now, lets continue and implement the gathering resource state:


var gathering_resource_timer = 0
func _gathering_resource(delta: float):
	if current_state == VillagerState.GATHERING_RESOURCE:
		gathering_resource_timer += delta
		animation_player.play("Idle")
		look_at(currently_gathering.global_position, Vector3.UP)
		velocity = Vector3(0,0,0) # Stop the unit while gathering
		if gathering_resource_timer >= currently_gathering.gather_time:
			carrying_amount += currently_gathering.gather_rate
			currently_gathering.amount -= currently_gathering.gather_rate
			carrying_resource_type = currently_gathering.resource_type
			gathering_resource_timer = 0
			# calculate path to town center to return resource (for returning resource state)
			var town_center = get_tree().get_nodes_in_group("town_centers")[0]
			calculate_path(town_center.global_position)
			if currently_gathering.amount <= 0:
				currently_gathering = null
				is_gathering = false
				

In the _gathering_resource, we expect the RTS unit to be already in contact with the resource and is thus gathering. Here, the unit should stay for some time (gather_time). And at the end of this time, it takes gather_rate amount of resource from the deposit, and calculates the path back to the town center. It also makes currently_gathering to null if the amount is depleted in the deposit, so unit no longer tries to take deposit from a resource that has already been depleted. Understand that while this is happening, the _check_state_transitions keeps listening to changes and will update the state back to some other state if the resource depletes.

Since the path to the town center is calculated, we can now handle _returning_resource (to return collected resource back to the town center). But here is the problem, how would we know that we have reached the town center? My approach is to use a RayCast3D node in Godot. So add a Raycast3D node and position it like this:

Assign this RayCast3D node to the @export variable we declared earlier named raycast3d 9see its declaration above).


func _returning_resource(delta: float):
	if current_state == VillagerState.RETURNING_RESOURCE:
		if current_agent_path_index < current_agent_path.size():
			var target_pos = current_agent_path[current_agent_path_index]
			target_pos.y = self.global_position.y # DO NOT TOUCH IT
			var direction = (target_pos - self.global_position).normalized()
			var distance = self.global_position.distance_to(target_pos)

			# Update velocity for move_and_slide
			velocity.x = direction.x * speed
			velocity.z = direction.z * speed

			# Play walk animation
			animation_player.play("Walk")
			# Rotate towards the target only along y-axis
			look_at(target_pos, Vector3.UP)


			# Check if agent reached the current target position
			if distance < 0.5:
				current_agent_path_index += 1  # Move to the next waypoint
				print("Reached waypoint:", current_agent_path_index)
		else:
			pass
		
		# Once reached the town center
		# Deposit the resource to the town center
		var hitting_object = get_hitting_object()
		if hitting_object:
			print("HITTING OBJECT DEPOSIT TIME: ", hitting_object.name)
			if hitting_object and hitting_object.name == "TownCenter":
				# Get name of enum instead of its value
				var resource_name: String = currently_gathering.RTSResourceTypes.keys()[carrying_resource_type].to_lower()
				hitting_object.deposit_resource(resource_name, carrying_amount)
				carrying_amount = 0
				carrying_resource_type = null
				print("DEPOSITED")

				# Calculate path for returning to the resource
				calculate_path(currently_gathering.global_position)



# Helper function to get the object our raycast3d node is touching
func get_hitting_object():
	if raycast3d.is_colliding():
		return raycast3d.get_collider()
	else:
		return null

And that’s all. If we instance the tree resource, town canter & the villager, we will see something like this:

Collecting wood in RTS game

Appendix: Stockpile GUI

I used a very simple setup in which a panel was used with a label to show the contents of the stockpile dictionary (see town center tutorial where I made stockpile as a simple dictionary). The only fancy thing about the GUI above is the texture.

Thank you for reading <3

Leave a Reply

Your email address will not be published. Required fields are marked *