Movable Component
The main logic of entity movement. It is modelled after common steering and boid logic and closely tuned to achieve a similar play and feel as Starcraft2.
This is by far to most complex component and probably deserves a future refactor. For now, we will go over the different states and explain the exposed properties in more detail.
State and Targets
RTS_Movable holds a list of RTS_Target, which describe the target position and meta data (for instance whether the target is another RTS_Entity itself, or if there are any callbacks to invoke when reaching a target etc).
Most notably, each target is associated with a type
enum Type {
NULL = 0, #NULL only used for signal emitting since can't emit null variable and for null checks
PATROL = 1, #Patrol back and forth between multiple targets
MOVE = 2,
ATTACK = 3, # Will move to attack a specific target, ignoring anything else.
MOVEATTACK = 4 #Moves towards target, but will auto attack enemy entities inbetween when possible.
}
signifying the type of movement towards this target. The types should be self explanatory to anyone who has played another RTS such as Warcraft or Starcraft.
At the same time, a RTS_Movable entity is always in one of the following states at a given time.
enum State {
IDLE = 0,
#if we don't need to follow targets, we don't need this state. This makes things much simpler.
#However, I'm keeping it in for now since it might be useful to readd "entity following" logic later
REACHED_SOURCE_TARGET = 1,
HOLD = 2,
PATROL = 3,
WALK = 4,
RETURN_TO_IDLE = 5, #Unit automatically returns to idle position
PUSHED = 6 # Unit is pushed by external forces
} # <= 2 means unit is stationary, > 2 is moving, > 3 is moving without patrol
Types and States are not necessarily the same, as the state describes what current state the movable component is in, whereas the target type gives information on how the next target should be treated.
Properties
@export_group("General")
@export var speed: float = 5
@export var stop_distance : float = 0.25
@export var pivot: Node3D #Node which gets rotated. Note: RTS_Entity node is never rotated to keep things simple
@export var steering : Area3D
The steering area is used for local avoidance and separation. As can be seen in the ExampleUnit, it is a sphere shape with a fairly small radius around the units, slightly larger thant the entities collision shape itself, used to find immediate neighbors. These neighbors are used for "separation" and "avoidance" explained further below
@export_group("Components")
@export var nav_agent: NavigationAgent3D
A NavigationAgent3D is required for the seeking component of the movement logic.
@export_group("Separation")
@export var use_separation :bool = true
@export var separation_multiplier : float = 1.0
@export_group("Avoidance")
@export var use_avoidance :bool = true
@export var avoidance_multiplier : int = 10
@export_group("Push")
@export var allow_being_pushed : bool = true
Separation, avoidance and pushing behaviour is what sets RTS_Movable entities apart from Godots standard implementation of NavigationAgents.
Separation enabled: If two moving entities are about to walk into each other, they a force will be applied (in opposite directions) which separates the entities, allowing them to smoothly pass each other.
Avoidance enabled: Avoidance is used to avoid, or walk around, immovable objects or immovable entities. Entities can be immovable, even when having a RTS_Movable component themselves, for example when the ability "RTS_HoldAbility" is activated. This default behaviour is implemented here,
func is_externally_immovable(_movable: RTS_Movable) -> bool:
return sm.current_state == State.HOLD
and can often be overriden by other scripts, using the active_controller override.
To test avoidance behaviour, try "holding" an entity (making it immovable) and then walk into it with another entity.
Allow Being Pushed enabled: There are numerous occasions when entities want to push each other (this does not count as separation). For instance an Idling entity (meaning the movement state is IDLE) will always be pushed away from a moving entity that is walking and colliding with it. Enabling this will make it easy and smooth to walk through your own entities. Certain abilities could also make use of this and push other entities out of the way.
Controller overrides
Movement being the most complex logic in RTS, there are many times when you want to override the behaviour temporarily, for instance because you enable a certain ability. To accomodate this, RTS_Movable holds a list of controllers,
#A list of (priority, controller) tuples that can overwrite this scripts physics process
var active_controller: Object #Either this or a class that overrides movement, i.e. RTS_AttackVariant
var controller_overrides: Array = []
of which only the highest priority controller is considered active and determins the exact movement logic.
# RTS_Movable:
func add_controller_override(controller: Object, priority: int) -> void:
controller_overrides.append({ "priority": priority, "controller": controller })
controller_overrides.sort_custom(func(a, b): return b["priority"] - a["priority"])
active_controller = controller_overrides[0].controller
func remove_controller_override(controller: Object) -> void:
controller_overrides = controller_overrides.filter(func(entry): return entry["controller"] != controller)
controller_overrides.sort_custom(func(a, b): return b["priority"] - a["priority"])
if controller_overrides.is_empty():
active_controller = null
else:
active_controller = controller_overrides[0].controller
For instance, the most common use is RTS_DefaultAttackVariant, which automatically overrides the default implementation of RTS_Movable when it is active:
# RTS_DefaultAttackVariant:
func set_component_active():
super.set_component_active()
if entity.movable != null:
entity.movable.add_controller_override(self,1)
func set_component_inactive():
super.set_component_inactive()
if entity.movable != null:
entity.movable.remove_controller_override(self)
Such a controller has to implement exactly two functions:
func physics_process_override_movable(delta: float, movable: RTS_Movable)
and
func is_externally_immovable(movable: RTS_Movable) -> bool:
of which we have already discussed the latter further above. This way, RTS_DefaultAttackVariant can implement (override) its own custom movement logic, which is needed because we might not won't to continue to move when we are attacking.
Note all the complex boid behaviours and state transitions are taken care of in RTS_Movable. Therefore the overriding controller doesn't have to implement custom movement logic itself, rather, it can run extra condition checks and determin which functions in the RTS_Movable should be execute. In most cases these controller still call
movable.sm.updatev([delta])
at some point, which runs the default movement logic in RTS_Movable. To clarify this point, imagine you only want the default movement logic to happen when the current time in seconds in divisible by two. You could add a controller, which checks the time modulo 2, and only calls updatev([delta]) when this condition is true.
Events
signal after_targets_added(movable: RTS_Movable, targets: Array[RTS_Target])
signal next_target_changed(movable: RTS_Movable) #onyl called for acute target change
signal before_all_targets_cleared(movable: RTS_Movable)
signal all_targets_cleared(movable: RTS_Movable)
signal next_target_just_reached(movable: RTS_Movable, target: RTS_Target) # called just before removal of index
signal final_target_reached(movable: RTS_Movable)