Devlog 5 - Workers!
This one will be about the very special unit that maybe we all take for granted in rts games. The humble workers!
When implementing workers in XYZ I wanted them to really feel like they do in starcraft, a relatively fast unit that can gather some sort of resource and return it back to it's main base. Simple enough...
I decided that since a Worker is such a special unit that it shouldnt be just another blueprint of an XYZUnit, but instead let's derive it down to an XYZWorker since it will need quite a few special functions.
Requirements for a worker
- Passive - meaning when in an
IDLE
state and an enemy goes near it shouldnt attack them by default - Can gather some sort of resource for now i decided to mimic starcraft and we can have minerals
- Can be told to gather minerals by selecting and right clicking a mineral patch
- Will sit there gathering a mineral until done and then must return it back to the closest base
- A resource can only have 2 active workers on it so they must be able to go to another patch if its full
- and of course when they have a resource display it in the client
So what is such a small simple unit turned into quite the task
First is first we need a resource actor that we can go gather
class AXYZResourceActor
virtual void Process(float DeltaSeconds) override;
void AddWorker(AXYZWorker* Worker);
void RemoveWorker(const AXYZWorker* Worker);
EXYZResourceType ResourceType = EXYZResourceType::MINERAL;
int32 RESOURCE_CAPACITY = 2;
TMap<AXYZActor*, bool> Workers;
int32 CurrentWorkers = 0;
I decided that each patch should have a way to keep track of what workers are mining and a bool value if they are active in the patch or if they are waiting for someone to finish mining.
After a little bit of time making some high quality models in blender boom we got the start!
Now we need to be able to tell what states our workers are in. I decided to split it into 3 possible states on top of all the other states a unit can have.
GATHERING
- when a worker is going towards a mineral patchMINING
- the actual action of being by the mineral and mining it for a resourceRETURNING
- The mineral is mined lets bring it back home
We already have a way to tell what inputs are being sent and who the target actor is, so with a little changes if we are selecting workers and follow command them to a mineral patch it signifies the server we want to put them in the GATHER
state and the same if we follow a base building and our worker is holding onto a resource we're telling it to set it to RETURNING
state. Great now let's handle all those state cases in our Process()
for our Worker!
Gathering
if (State == EXYZUnitState::GATHERING) {
if (!TargetActor) {
FindClosestResource();
}
if (TargetActor) {
Gather();
}
else {
GetXYZAIController()->XYZStopMovement();
}
}
Pretty simple if we dont have a target actor AKA a resource find the closest one
if we have a target resource go gather it if all else fails tell the controller to stop which will set the worker idle
void Gather() {
FVector ActorLocation = GetActorLocation() + GetActorForwardVector() * CurrentCapsuleRadius;
FVector2D ActorLocation2D = FVector2D(ActorLocation.X, ActorLocation.Y);
if (TargetActor && TargetActor->Health > 0.0f) {
// Code to get distance to the resource
float DistanceToTarget = FVector2D::Distance(ActorLocation2D, TargetLocation2D);
if (DistanceToTarget <= CurrentCapsuleRadius * 1.5f)
{
SetState(EXYZUnitState::MINING);
ResourceActor->AddWorker(this);
GetXYZAIController()->StopMovement();
}
}
}
in our gather function we check the distance to the resource and if in range we go into our mining state
before we go into mining let's take a look at how we find the closest resource
void FindClosestResource()
{
TArray<AActor*> FoundResources;
UGameplayStatics::GetAllActorsOfClass(GetWorld(), AXYZResourceActor::StaticClass(), FoundResources);
int32 SmallestCapacity = INT_MAX;
float ClosestDistance = FLT_MAX;
AXYZResourceActor* SmallestCapacityResource = nullptr;
for (AActor* Resource : FoundResources)
{
float Distance = FVector::Dist(Resource->GetActorLocation(), GetActorLocation());
AXYZResourceActor* ResourceActor = Cast<AXYZResourceActor>(Resource);
if (ResourceActor && Distance <= 1000.0f)
{
if(ResourceActor->Workers.Num() < SmallestCapacity)
{
SmallestCapacityResource = ResourceActor;
SmallestCapacity = ResourceActor->Workers.Num();
ClosestDistance = Distance;
}
else if(ResourceActor->Workers.Num() == SmallestCapacity && Distance < ClosestDistance)
{
SmallestCapacityResource = ResourceActor;
SmallestCapacity = ResourceActor->Workers.Num();
ClosestDistance = Distance;
}
}
}
TargetActor = SmallestCapacityResource;
}
We go through all the resources in the map and find the closest one but we also prioritize the resources with the smallest capacity AKA resources that have the least workers mining on them.
NOTE: Going through all the resources is something I don't like and in the future i plan to box the resource actors in a mineral field actor and only look through the closest mineral field
Mining
What really is mining in an RTS for me I boiled it down to being the active worker at a mineral patch and consecutively being in the mining state for a certain amount of ticks. Once that is complete we can flag that our worker is holding a resource.
else if (State == EXYZUnitState::MINING)
{
AXYZResourceActor* Resource = Cast<AXYZResourceActor>(TargetActor);
if(!Resource || !Resource->Workers.Contains(this))
{
SetState(EXYZUnitState::GATHERING);
}
else
{
if(HeldResource != EXYZResourceType::NONE)
{
StartReturningResource();
}
else if(TimeToGather >= GatherRate)
{
TargetActor->Health = FMath::Clamp(TargetActor->Health - AttackDamage, 0.0f, TargetActor->MaxHealth);
TimeToGather = 0;
StartReturningResource();
}
else
{
if(Resource->Workers[this])
{
TimeToGather += DeltaTime;
}
else if(Resource->Workers.Num() < Resource->RESOURCE_CAPACITY)
{
Resource->Workers[this] = true;
}
else
{
Resource->RemoveWorker(this);
TargetActor = nullptr;
SetState(EXYZUnitState::GATHERING);
}
}
}
}
First if the resource is gone or we are not in the resource map we go back to gathering.
If we have a held resource we set the state to RETURNING
meaning that if you tell a worker to mine but it already has a resource itll just go return it.
The rest is that timer logic where we increment TimeToMine
until it reaches GatherRate
then we tell it that it has a resource and go return it.
Returning
I took another page out of starcraft and decided to not tie down a worker to a specific base, but instead the closest one. In the event your base gets destroyed your worker will then try to return the resource to another base you had.
if (State == EXYZUnitState::RETURNING) {
bHasAvoidance = false;
FindClosestBase();
if (ClosestBase) {
Return();
}
else {
GetXYZAIController()->XYZStopMovement();
}
}
FindClosestBase
just looks for the closest XYZBuildingBase
you have in the game and sets the ClosestBase
variable to that Building's pointer
void Return() {
FVector ActorLocation = GetActorLocation() + GetActorForwardVector() * CurrentCapsuleRadius;
FVector2D ActorLocation2D = FVector2D(ActorLocation.X, ActorLocation.Y);
if (ClosestBase && State == EXYZUnitState::RETURNING && HeldResource != EXYZResourceType::NONE) {
UCapsuleComponent* CapsuleComp = ClosestBase->GetCapsuleComponent();
FVector ClosestPoint;
CapsuleComp->GetClosestPointOnCollision(ActorLocation, ClosestPoint);
FVector2D TargetLocation2D = FVector2D(ClosestPoint.X, ClosestPoint.Y);
float DistanceToTarget = FVector2D::Distance(ActorLocation2D, TargetLocation2D);
if (DistanceToTarget <= CurrentCapsuleRadius*3.0f && HeldResource != EXYZResourceType::NONE)
{
if (HeldResource == EXYZResourceType::MINERAL) {
GetWorld()->GetAuthGameMode()->GetGameState<AXYZGameState>()->MineralsByTeamId[TeamId] += 5;
}
else {
GetWorld()->GetAuthGameMode()->GetGameState<AXYZGameState>()->GasByTeamId[TeamId] += 5;
}
HeldResource = EXYZResourceType::NONE;
if(!TargetActor)
{
FindClosestResource();
}
if (TargetActor && TargetActor->IsA(AXYZResourceActor::StaticClass())) {
GetXYZAIController()->XYZGatherResource(Cast<AXYZResourceActor>(TargetActor));
}
else {
SetState(EXYZUnitState::IDLE);
GetXYZAIController()->XYZStopMovement();
}
}
}
}
We move our towards the closest base and once in range we add those minerals to our players bank. And then reset the cycle back to GATHERING
Final Notes
There is one last thing I didn't mention and that is collision. In starcraft when a worker is gathering/mining/returning they have no collision with other units leading to mechanics like running your workers through another players base. Or on the other case stacking your workers to only get AoEd down by banelings :(
I liked that mechanic so when a worker is in one of the 3 states it's collision gets set to a new preset MineralWalk
where it has no collision with other units and we also turn off the avoidance because since there is no collision there is no point to avoid units.
This is still a bit buggy on the client and it's something i'm still in the process of debugging, but I thought it'd be good to mention.
Anyways onto the GIFs!
Since we set the workers to a gathering state at the start of the game they'll auto mine
The auto split mechanic of finding the closest mineral with the least capacity
We blew up our base whoops! (With some stragglers bug oops...)
Here is our no collision mineral walking
And here is the bug I was speaking on the client side it thinks theres some collision, but for now on the server no collision is happening
We'll if you made it here thanks for reading! There's still a few bugs I need to iron out, but so far I'm happy with the progress I made.
Update!
I wasn't happy about the pathing bug, so I digged a little and realized that I was making the collision preset to MineralWalk
for only the SkeletalMesh and not the CapsuleCollider
After updating both during 3 states we have...
perfection!
If you have any questions feel free to email me at jandro@xyzrts.com