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:
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:
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