XYZ

Devlog 7 - Building Buildings That Build

I think I've progressed enough developing my building system that it merits it's own long devlog. This is going to be woozy, so strap in...

What are buildings in an RTS?

In the simplest terms, a building is a type of unit. Let's list what it should be able to do.

So yeah buildings are very specialized units in an RTS that can do as much as a regular unit and then more.

How do we build the buildings?

Well if you read my worker devlog then you can kind of figure out how we will be building are out units. Once again the worker comes in to the rescue!

High Level Flow of building a building

Select(Worker)
Worker->UseAbility(AbilityIndex)
ProcessAbility(XYZPlacementAbility)
Worker->SetState(PLACING)
if (InRange(Worker, PlacingLocation)) Worker->PlaceBuilding()
SpawnUnit(BuildingTemplate)
Building->SetState(BUILDING)
if (TimeToBuild >= TotalBuildTime) Building->SetState(BUILT)

XYZAbility and XYZPlacementAbility

XYZAbility is the base class for all unit abilities. For example in terms of starcraft a marine in it's command card has abilities like Move,Hold,Stimpack. I've modeled my XYZAbility class to work in a similar way. A unit has an array of XYZAbility our XYZPlayerController maps keybinds to ability indexes

Q = 0
W = 1
E = 2
R = 3
...

for (int32 i = 0; i < AbilityInputActions.Num(); i++) {
			EnhancedInputComponent->BindAction(
				AbilityInputActions[i],
				ETriggerEvent::Started,
				this,
				&AXYZPlayerController::OnAbilityInputStarted,
				i);
}

So when we press Q we will trigger the ability action of index 0 for our selected units.

void AXYZPlayerController::OnAbilityInputStarted(int32 AbilityIndex) {
// Handle ability input
}

Normally abilities are handled on the input start immediately, if you've ever played a game like dota 2 or league, this will be the same as a smart cast ability. But an XYZPlacementBuilding has an additional complexity. We don't want to smartcast a placement ability as instead we want to show the player if they can build at the selected location.

To do this we need to add some data to our XYZPlacementAbility

class UXYZPlacementAbility : public UXYZAbility
	TSubclassOf<class AXYZBuilding> PlacementTemplate;
	TSubclassOf<class AXYZBuilding> BuildingTemplate;
	class AXYZWorker* OwningWorker;
	bool bCanCancel = true;
	virtual bool Activate() override;
	FVector2D GridSize;
	FVector BuildingLocation;

PlacementTemplate will be spawned on the action it is a copy of the building itself with most it's data stripped out. This will just be used on the client side to show the player where it can place the building.

When we activate the ability we spawn the PlacementTemplate and have it follow the players mouse with an offset so it's always lined up to our map grid.

if(PlacementBuilding)
		FVector NewLocation = GetMouseToWorldPosition(this);
		NewLocation = SnapToGridCenter(NewLocation);

		PlacementBuilding->SetActorLocation(NewLocation);

		bIsBuildingPlaceable = CanPlaceBuilding(NewLocation, PlacementBuilding->GridSize.X, PlacementBuilding->GridSize.Y);

XYZ

Now we need to actually tell the server we want to use this ability once we click the location. We can keep track of if our Placement is active, and if so we send the server our XYZPlacementAbility

switch (InputType) {
		case EXYZInputType::PRIMARY_INPUT:
			if(bIsPlacingBuilding && PlacementBuilding && bIsBuildingPlaceable)
			{
				QueueInput(AbilityInput);
			}

Now our input ability is handled upstream by our InputManger and then processed by our BlobManager you can check out my devlog on blobs if you'd like to learn more. But in short our input is sent to the server and that ability gets turned into an XYZAction that our worker will execute.

void ProcessAction(TSet<AXYZActor*> Agents)
{
	AXYZWorker* AvailableWorker = nullptr;
	for (AXYZActor* Actor : Agents) {
AXYZWorker* Worker = Cast<AXYZWorker>(Actor);
		if (Worker && Worker->ActorId == ActiveActorId && Worker->State != EXYZUnitState::PLACING && Worker->State != EXYZUnitState::BUILDING) {
			AvailableWorker = Worker;
		}
	}
	
	if(AvailableWorker)
	{
		AvailableWorker->Abilities[AbilityIndex]->BuildingLocation = TargetLocation;
		AvailableWorker->UseAbility(AbilityIndex);
	}
}

We make sure to only take in one of our selected workers we don't want them all to go build the same building. And then we execute the ability on the server side.

bool Activate()
{
	if(Super::Activate())
	{
		if(XYZGameState->MineralsByTeamId[OwningWorker->TeamId] >= MineralCost)
		{
			OwningWorker->TargetLocation = BuildingLocation;
			OwningWorker->ActivePlacementAbility = this;
			XYZGameState->MineralsByTeamId[OwningWorker->TeamId] -= MineralCost;
			OwningWorker->SetState(EXYZUnitState::PLACING);
			return true;
		}
	}
	return false;
}

We need to make sure we have enough resources to build it and if we do we set our worker to go into it's PLACING

There's two mains states for building

XYZ

Once the building is spawned it's set to PLACED and it will then go to BUILDING state where it will build itself over time until it reaches it's build time and then will be set to BUILT once a building is BUILT it can be used.

case EXYZBuildingState::PLACED:
        BuildingState = EXYZBuildingState::BUILDING;
        break;
    case EXYZBuildingState::BUILDING:
        if(TimeToBuild >= TotalBuildTime)
        {
            BuildingState = EXYZBuildingState::BUILT;
            SupplyByTeamId[TeamId + 2] += SupplyGain;
            if(ActorCache)
            {
                ActorCache->AddActorCount(TeamId, ActorId);
            }
        }else
        {
            Build(DeltaTime);
        }
        break;
    case EXYZBuildingState::BUILT:
        ProcessBuildQueue(GetWorld()->DeltaTimeSeconds);

I left a few details mostly the pathing of workers and them having to find a valid location to build the building. But I'll leave it for when I create a devlog on movement/pathing

Researching Upgrades

Upgrades are mostly simple in practice. Unit's have stats and upgrades globally increases their stats. For example in starcraft marines can have have combat shields these globally add 10 hp to all marines for the rest of the game this is also retroactive so all marines in the current game will also gain this effect. There are also upgrades that have multiple stages Attack 1,2,3 which can be researched in stages.

Now how do we do all this, well a building is a unit, so we can give it abilities.

class UXYZUpgradeAbility : public UXYZBuildingAbility
{
	TSet<int32> AffectedActorIds;
	TMap<EXYZStat, float> StatGainMap;
	int32 CurrentStage;
	int32 MaxStage;
	TArray<int32> MineralCostByStage;
	TArray<FString> NameByStage;

	void UpgradeActor(class AXYZActor* Actor);
	void UpgradeActorStat(EXYZStat Stat, int32 StatGain, AXYZActor* Actor);
	void UpdateStage(int32 Stage);

Simple stat upgrades can just be processed in our buildings build queue as it were a unit. There are some checks we need to make for our upgrades though

This is all tracked in our new manager UXYZUpgradeManager

class UXYZUpgradeManager : public UObject, public IProcessable
	void Process(float DeltaTime) override;
	TArray<TMap<int32, class UXYZUpgradeAbility*>> UpgradesByTeam;
	TArray<TSet<int32>> UpgradesInResearch = { {},{}};
	TSet<UXYZUpgradeAbility*> UpgradeAbilities;
	TSet<UXYZUpgradeAbility*> UpgradeAbilitiesToRemove;

	bool ContainsUpgrade(UXYZUpgradeAbility* UpgradeAbility);
	bool IsUpgradeBeingResearched(UXYZUpgradeAbility* UpgradeAbility);
	void RemoveUpgradeFromResearch(UXYZUpgradeAbility* UpgradeAbility);
	void AddUpgradeToResearch(UXYZUpgradeAbility* UpgradeAbility);
	void AddUpgradeAbility(UXYZUpgradeAbility* Ability);

So just like any other ability we go through the flow telling the server we are using an ability. The server checks to make sure it's all valid and then it creates the action for the building to enqueue this ability in it's build queue.

void AXYZBuilding::ProcessBuildQueue(float DeltaTime) {
    if (BuildQueue.IsEmpty()) return;

    UXYZBuildingAbility* CurrentAbility = *BuildQueue.Peek();
    if (!CurrentAbility) {
        CancelProduction();
        return;
    }

    if (!bIsTraining) {
        bIsTraining = true;
        TimeToBuild = 0.0f;
        TotalBuildTime = CurrentAbility->BuildTime;
        
    }

    if (TimeToBuild >= TotalBuildTime) {
        UXYZUpgradeAbility* UpgradeAbility = Cast<UXYZUpgradeAbility>(CurrentAbility);
        if(UpgradeAbility)
        {
            ResearchUpgrade(UpgradeAbility);
        }
        bIsTraining = false;
        CancelProduction();
        return;
    }

    if (bIsSupplyReserved) {
        TimeToBuild += DeltaTime;
    }
}

ResearchGif

Training Units

To wrap it all off we need to also be able to train units from buildings. Just like the upgrades they we be added onto the building queue, but with a few extra steps.

We need to make sure that our current supply + unit supply <= max supply There's a few rules I took from starcraft you can keep queuing units onto the building even if you dont have the current supply they will just not get trained until you have enough supply. When a unit is being trained it reserves it's supply so you can't train multiple units if theres not enough supply to reserve.

The whole ability flow is the same as the research we just need to add some logic to the processing of our queue.

void AXYZBuilding::ProcessBuildQueue(float DeltaTime) {
    if (BuildQueue.IsEmpty()) return;

    UXYZBuildingAbility* CurrentAbility = *BuildQueue.Peek();
    if (!CurrentAbility) {
        CancelProduction();
        return;
    }

    AXYZGameState* GameState = GetWorld()->GetGameState<AXYZGameState>();
    if (!GameState) return;
    int32 CurrentSupply = GameState->SupplyByTeamId[TeamId];
    int32 MaxSupply = GameState->SupplyByTeamId[TeamId + 2];

    bool bIsSupplyReserved = GameState->ReservedSupplyByBuilding[TeamId].Contains(UActorId);

    if (!bIsTraining) {
        bIsTraining = true;
        TimeToBuild = 0.0f;
        TotalBuildTime = CurrentAbility->BuildTime;
        
    }

    if (!bIsSupplyReserved && CurrentSupply + CurrentAbility->SupplyCost <= MaxSupply) {
        GameState->ReservedSupplyByBuilding[TeamId].Add(UActorId, CurrentAbility->SupplyCost);
        GameState->SupplyByTeamId[TeamId] += CurrentAbility->SupplyCost;
    }

    // IF OVERCAPPED AND IS RESERVING REMOVE RESERVE AND SUBTRACT FROM SUPPLY ALSO RESET TRAINING
    if (CurrentSupply > MaxSupply && bIsSupplyReserved) {
        TimeToBuild = 0.0f;
        GameState->ReservedSupplyByBuilding[TeamId].Remove(UActorId);
        GameState->SupplyByTeamId[TeamId + 2] -= CurrentAbility->SupplyCost;
        return;
    }

    // CURRENT SUPPLY + SUPPLY COST >= MAX RESET BUILD TIME
    if (CurrentSupply > MaxSupply && !bIsSupplyReserved) {
        TimeToBuild = 0.0f;
        return;
    }

    if (TimeToBuild >= TotalBuildTime) {
        UXYZUpgradeAbility* UpgradeAbility = Cast<UXYZUpgradeAbility>(CurrentAbility);
        if(UpgradeAbility)
        {
            ResearchUpgrade(UpgradeAbility);
        }else
        {
            if(HasValidSpawnPoint())
            {
                TrainUnit(CurrentAbility->UnitTemplate);
            }else
            {
                return;
            }
        }
        bIsTraining = false;
        CancelProduction();
        return;
    }

    if (bIsSupplyReserved) {
        TimeToBuild += DeltaTime;
    }
}

When we call train unit we'll spawn that unit in a valid location around the perimeter of the building in the direction of our rally point that can set from the client.

void AXYZBuilding::TrainUnit(TSubclassOf<class AXYZActor> UnitTemplate) {
    FActorSpawnParameters SpawnParams;
    SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButAlwaysSpawn;

    FVector SpawnLocation = ValidSpawnPoint;
    FVector Start = SpawnLocation + FVector(0, 0, 1000);
    FVector End = SpawnLocation - FVector(0, 0, 10000); 

    FHitResult HitResult;

    bool bHit = GetWorld()->LineTraceSingleByChannel(HitResult, Start, End, ECC_WorldStatic);

    if (bHit)
    {
        SpawnLocation.Z = HitResult.Location.Z;
    }
    
    AXYZActor* SpawnActor = GetWorld()->SpawnActor<AXYZActor>(UnitTemplate, SpawnLocation, FRotator::ZeroRotator, SpawnParams);
    SpawnActor->TeamId = TeamId;
    GetWorld()->GetGameState<AXYZGameState>()->SupplyByTeamId[SpawnActor->TeamId + 2] += SpawnActor->SupplyGain;
    GetWorld()->GetAuthGameMode()->GetGameState<AXYZGameState>()->AddActorServer(SpawnActor);

    UXYZBuildingAbility* CurrentAbility = *BuildQueue.Peek();
    CurrentAbility->bCanCancel = false;

    if (RallyTarget) {
        if (RallyTarget->TeamId == 2) {
            SpawnActor->GetXYZAIController()->XYZMoveToActor(RallyTarget);
        }
        else if (RallyTarget->TeamId == TeamId) {
            SpawnActor->GetXYZAIController()->XYZFollowTarget(RallyTarget);
        }
        else {
            SpawnActor->GetXYZAIController()->XYZAttackMoveToTarget(RallyTarget);
        }
    }
    else if (SpawnPoint != RallyPoint) {
        SpawnActor->GetXYZAIController()->XYZMoveToLocation(RallyPoint);
    }
}

Our rally point can be linked to units as well so we need to pass in an initial action to our new unit if that is the case. Attacking target enemy units and following ally units.

RallyUnits

And there we have our buildings. Just remember to build additional pylons :)

I might end up splitting this up into separate blog posts, and go indepth on each part, but for now I just wanted to get my thoughts out there on my building system.

As always feel free to reach out to me at jandro@xyzrts.com

View original