How to Make RTS Military Unit

rts game military unit attacking

So far, we made only a generic unit (base Unit). Although the scene (in previous tutorials) had a warrior character, but the script that was attached was Unit.gd. It only knew how to walk & stay idle (i.e., only had states WALKING & IDLE).

In this post, we will extend Unit, and create an actual Warrior. The warrior will have additional states such as FIGHTING & FOLLOWING_TARGET (chasing). The warrior will handle these states & its transitions.

Getting Started with our Military Unit

The core idea is to create a script named “Warrior.gd” (or “MilitaryUnit.gd”, whatever you prefer). And then attach this script to the warrior scene (which is same as older scene we used for the Unit, if it has fighting animations). In the warrior script, extend from base Unit class:


extends Unit

# Additional states for Warrior
enum WarriorState {
	ATTACKING = 100, FOLLOWING_TARGET,
}

var attack_range = 5
var currently_attacking: Unit = null

func _ready():
	super() # call base class _ready() function

func _process(delta):
	super(delta) # call base class _process() function

func _physics_process(delta):
	super(delta) # call base class _physics_process() function

Trigger the warrior to attack a Unit

In RTS games, we typically select a military (warrior) unit & order them to attack some other (enemy) unit. This is the reason why I declared currently_attacking variable above. Add following code under the _physics_process method to implement this logic:

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_00000010)
		if target:
			if target in get_tree().get_nodes_in_group("units") and target != self:
				currently_attacking = target
		else:
			currently_attacking = null

The above piece of code determines who we are currently_attacking. Again, I used our raycast system which we made in earlier tutorials & passed collision_mask for units in the parameter.

Handling Warrior States & Transitions

So far, we only assigned who to attack. But our warrior will likely do nothing more than moving to the target position, due to the influence of base class, which makes the current state to be MOVING when we click somewhere in the map.

To make use of warrior states, we override _check_state_transitions and _process_all_states methods of our base Unit class. Lets do this.

# @Override
func _check_state_transitions(delta: float):
	super(delta) # Must call base class method so some states are handled by the base class
	
	# Handle attacking and following
	if currently_attacking:
		if currently_attacking.health <= 0: # If enemy is dead
			current_state = UnitState.IDLE # become idle
			return # no need to process more states, return immediately
		
		var distance = (self.global_position * Vector3(1, 0, 1)).distance_to(currently_attacking.global_position * Vector3(1, 0, 1))
		if distance > attack_range:
			current_state = WarriorState.FOLLOWING_TARGET
		else:
			current_state = WarriorState.ATTACKING
	
	else:
		# Reset state if not attacking
		if current_state in [WarriorState.ATTACKING, WarriorState.FOLLOWING_TARGET]:
			current_state = UnitState.IDLE



# @Override
func _process_all_states(delta: float):
	super(delta)
	_process_following_target_state()
	_process_attacking_state(delta)

Again, I assigned the state based on different conditions (in _check_state_transitions). There must be a condition that needs to be true to lead to a transition (change of state). Also, I called super() to make sure the base class handles the states that are not handled by the Warrior class.

_process_all_states also handles the warrior states, but they are not implement right now. Lets implement them:


# Basically walking logic replicated. TODO: apply DRY principle
func _process_following_target_state():
	if current_state == WarriorState.FOLLOWING_TARGET:
		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
			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:
				# Move to the next waypoint
				current_agent_path_index += 1  # Move to the next waypoint
				print("Reached waypoint:", current_agent_path_index)
		else:
			calculate_path(currently_attacking.global_position)



func _process_attacking_state(delta):
	if current_state == WarriorState.ATTACKING:
		if currently_attacking:
			# Attack the target
			animation_player.play("Sword_Attack2")
			animation_player.get_animation("Sword_Attack2").loop_mode = Animation.LOOP_LINEAR
			look_at(currently_attacking.global_position, Vector3.UP)
			currently_attacking.health -= 12 * delta  # Damage per frame


Note: Notice the logic for following a target (in _process_following_target_state) is same as unit moving. Now here comes the first mistake that I think I made. If we keep coding in this way, we will soon end up with tons of functions that implement the same logic. We are going against DRY principle in programming. – In my opinion, it will be better if we somehow dissolve the chasing state & make it an application of MOVING state implemented by the base Unit, since there are no differences in their logic, other than that the target is not static in case of FOLLOWING_TARGET.

In the other function (_process_attacking_state), it just looks at the enemy and keeps playing attack animation. It also reduce the health every frame by small amount.

Finally, a very slight change. We override _process_idle_state of base Unit, only to make currently_attacking null in case state becomes IDLE:

# @Override
func _process_idle_state():
	super() # Rest of logic of idle remains same, thus calling super()
	if current_state == UnitState.IDLE:
		currently_attacking = null

Congrats, we created a warrior that can fight!

RTS game killing enemy unit
dead unit rts game
A unit whose health < 0, after being killed by another unit.

Leave a Reply

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