📜 ⬆️ ⬇️

Writing the motion component for RTS in Unreal engine 4

image

Hi, my name is Dmitry, I am a programmer. I just finished refactoring the ship movement component for a real-time tactical game project in which players can assemble their own space fleet and lead it into battle. The movement component has been in correspondence three times already, from the release to the beginning of the development of the alpha version. A lot of rakes were collected, both architectural and network. I will try to bring all this experience and tell you about the Navigation Volume, Movement component, AIController, Pawn.

Task: to implement the process of moving a spacecraft on a plane.

Conditions of the problem:

Process architecture


We divide the task into two stages: the first is the search for the optimal path, the second is the movement to the end point under the cursor.
')
Task one. Search for the best path

Consider the conditions and architecture of the process of finding the optimal path in the Unreal engine 4. Our UShipMovementComponent is a component of the movement that is inherited from the UPawnMovementComponent , since the final unit, the ship, will be the heir of APawn .

In turn, UPawnMovementComponent is a descendant of UNavMovementComponent , which adds FNavProperties to it in its composition - these are navigation parameters describing the given APawn , and which AIController will use when searching for the path.

Suppose we have a level at which our ship is located, static objects and a navigation volume covering it. We ship the ship from one point of the map to another and this is what happens inside UE4:

scheme41.jpg

1) APawn , finds ShipAIController within itself (in our case, it is just the heir of AIController , which has one single method) and calls the path search method we created.
2) Inside this method, we first prepare a request to the navigation system, then send it and get control points of motion.

TArray<FVector> AShipAIController::SearchPath(const FVector& location) { FPathFindingQuery Query; const bool bValidQuery = PreparePathfinding(Query, location, NULL); UNavigationSystem* NavSys = UNavigationSystem::GetCurrent(GetWorld()); FPathFindingResult PathResult; TArray<FVector> Result; if(NavSys) { PathResult = NavSys->FindPathSync(Query); if(PathResult.Result != ENavigationQueryResult::Error) { if(PathResult.IsSuccessful() && PathResult.Path.IsValid()) { for(FNavPathPoint point : PathResult.Path->GetPathPoints()) { Result.Add(point.Location); } } } else { DumpToLog("Pathfinding failed.", true, true, FColor::Red); } } else { DumpToLog("Can't find navigation system.", true, true, FColor::Red); } return Result; } 

3) These points are returned by APawn 's list in a convenient format ( FVector ). Further, the process of movement starts.
Basically, APawn has ShipAIController , which when it calls PreparePathfinding (), calls APawn and receives a UShipMovementComponent , inside which it finds FNavProperties , which it passes to the navigation system to find the path.

Task two. Movement to the end point.

So, we returned a list of control points of the movement. The first point is always our current position, the last is our destination. In our case, this is the place where we clicked the cursor, sending the ship.
Here it is worth making a small digression and tell about how we are going to build work with the network. Divide it into steps and write out each of them:

1) We call the start method - AShip :: CommandMoveTo () :

 UCLASS() class STARFALL_API AShip : public APawn, public ITeamInterface { ... UFUNCTION(BlueprintCallable, Server, Reliable, WithValidation, Category = "Ship") void CommandMoveTo(const FVector& location); void CommandMoveTo_Implementation(const FVector& location); bool CommandMoveTo_Validate(const FVector& location); ... } 

Pay attention - on the client side, all Pawns have no AIController , they are only on the server. Therefore, when the client calls the ship dispatch method to the new location, we must perform all the errors on the server. In other words, the search for the path for each ship will be occupied by the server. Because it is AIController that works with the navigation system.
2) Once inside the CommandMoveTo () method, we found a list of control points, we call the next one to start the movement of the ship. This method must be called on all clients.

 UCLASS() class STARFALL_API AShip : public APawn, public ITeamInterface { ... UFUNCTION(BlueprintCallable, Reliable, NetMulticast, Category = "Ship") void StartNavMoveFrom(const FVector& location); virtual void StartNavMoveFrom_Implementation(const FVector& location); ... } 

In this method, a client who has no control points includes the first coordinate transferred to him in the list of control points and “starts the engine”, starting the movement. At this moment, on the server, we start sending the remaining intermediate and end points of our path through timers:

 void AShip::CommandMoveTo(const FVector& location) { ... GetWorldTimerManager().SetTimer(timerHandler, FTimerDelegate::CreateUObject(this, &AShip::SendNextPathPoint), 0.1f, true); ... } 

 UCLASS() class STARFALL_API AShip : public APawn, public ITeamInterface { ... FTimerHandle timerHandler; UFUNCTION(BlueprintCallable, Reliable, NetMulticast, Category = "Ship") void SendPathPoint(const FVector& location); virtual void SendPathPoint_Implementation(const FVector& location); ... } 

On the client side, as the ship begins to accelerate and move to the first control point of its path, it gradually gets the rest and puts them into an array. This allows us to unload the network and stretch the sending data in time, distributing the load on the network.

We finish with the retreat and return to the essence of the issue. The current task is to start the flight towards the nearest control point. Note that under the conditions, our ship has a turning speed, acceleration and maximum speed. Consequently, at the moment of sending to a new destination, the ship can, for example, fly at full speed, stand, only accelerate, or be in the process of turning. Therefore, the ship must conduct itself differently, based on the current speed characteristics and destination. We have identified three main lines of behavior of the ship:

scheme3.png


So before you start moving to a point, we need to decide what speed parameters we will fly. To do this, we implement the method of flight simulation. I will not give her code here, if someone is very interested - write, tell. The essence of it is simple - we, using the current DeltaTime , all the time we move the vector of our position and turn the direction of gaze forward, simulating the rotation of the ship. These are the simplest operations on vectors, with the participation of the FRotator . With a little effort of imagination, you realize it easily.

The only point worth mentioning is that in each iteration of the turn of the ship you need to remember how far we have turned it. If it is more than 180 degrees, this means that we are starting to circle around the destination point and we need to try the following speed parameters to try to get to the control point. Naturally, at first we are trying to fly at full speed, then at a reduced one (we are now working at an average speed), and if none of these options came up, it means the ship should just turn around and fly.

I want to draw your attention that all the logic of assessing the situation and the processes of movement should be implemented in AShip - because AIController 'and we do not have on the client, and the UShipMovementComponent plays a different role (about it a little lower, we have almost reached it). Therefore, in order for our ships to move independently and without constant synchronization of coordinates with the server (which is not necessary), we must implement the motion control logic inside the AShip .

So now the most important thing about all of this is our component of the UShipMovementComponent movement. It should be realized that the components of these types are motors. Their function is to give gas forward and rotate an object. They do not think about what logic the object should move, they do not think about the state of the object. They are only responsible for the actual movement of the object. For fuel injection and space shift. The logic of working with UMovementComponent and his heirs is as follows: we are at the Tick ​​() given to us, do all our mathematical calculations related to the parameters of our component (speed, maximum speed, speed of rotation), and then set the UMovementComponent :: Velocity parameter to relevant to the shift of our ship in this tick, then call UMovementComponent :: MoveUpdatedComponent () - this is where the shift of our ship and its rotation.

 void UShipMovementComponent::TickComponent(float DeltaTime, enum ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) { Super::TickComponent(DeltaTime, TickType, ThisTickFunction); if(!PawnOwner || !UpdatedComponent || ShouldSkipUpdate(DeltaTime)) { return; } if (CheckState(EShipMovementState::Accelerating)) { if (CurrentSpeed < CurrentMaxSpeed) { CurrentSpeed += Acceleration; AccelerationPath += CurrentSpeed*DeltaTime; } else { CurrentSpeed = CurrentMaxSpeed; RemoveState(EShipMovementState::Accelerating); } } else if (CheckState(EShipMovementState::Braking)) { if (CurrentSpeed > 0.0f) { CurrentSpeed -= Acceleration; DeaccelerationPath += CurrentSpeed*DeltaTime; } else { CurrentSpeed = 0.0f; CurrentMaxSpeed = MaxSpeed; RemoveState(EShipMovementState::Braking); RemoveState(EShipMovementState::Moving); } } else if (CheckState(EShipMovementState::SpeedDecreasing)) { if (CurrentSpeed > CurrentMaxSpeed) { CurrentSpeed -= Acceleration; DeaccelerationPath += CurrentSpeed*DeltaTime; } else { CurrentSpeed = CurrentMaxSpeed; RemoveState(EShipMovementState::SpeedDecreasing); } } if (CheckState(EShipMovementState::Moving) || CheckState(EShipMovementState::Turning)) { MoveForwardWithCurrentSpeed(DeltaTime); } } ... void UShipMovementComponent::MoveForwardWithCurrentSpeed(float DeltaTime) { Velocity = UpdatedComponent->GetForwardVector() * CurrentSpeed * DeltaTime; MoveUpdatedComponent(Velocity, AcceptedRotator, false); UpdateComponentVelocity(); } ... 

I will say two words about the states that appear here. They are necessary in order to combine different processes of movement. We can, for example, reduce the speed (because for maneuver we need to go to the average speed) and turn in the direction of the new destination point. In the motion component, we use them only to evaluate work with speed: do we need to continue to pick up speed, or its reduction, etc. All the logic relating to transitions from one state of motion to another, as I said, occurs in AShip : for example, we go at maximum speed, and we are changed to a destination point, and to achieve it we need to slow down to the average.

And the last two pennies about AcceptedRotator . This is our turn of the ship in this tick. In the AShip tick, we call the following method of our UShipMovementComponent :

 bool UShipMovementComponent::AcceptTurnToRotator(const FRotator& RotateTo) { if(FMath::Abs(RotateTo.Yaw - UpdatedComponent->GetComponentRotation().Yaw) < 0.1f) { return true; } FRotator tmpRot = FMath::RInterpConstantTo(UpdatedComponent->GetComponentRotation(), RotateTo, GetWorld()->GetDeltaSeconds(), AngularSpeed); AcceptedRotator = tmpRot; return false; } 

RotateTo = (GoalLocation - ShipLocation) .Rotation () - i.e. This is a rotator, which means in what value the rotation of the ship should be in order to look at the destination point. And in this method, we estimate, but does not the ship look at the destination point? If it looks, then such a result is returned, then we no longer need to turn around. And our AShip, in its logic of assessing the situation, will reset the state of EShipMovementState :: Turning - and the UShipMovementComponent will no longer seek to rotate. Otherwise, we take the rotation of the ship and interpret it taking into account the DeltaTime and the speed of the turn of the ship. Then we apply this turn in the current tick, when calling UMovementComponent :: MoveUpdatedComponent .

Perspectives


It seems to me that in this reincarnation of the UShipMovementComponent all the problems that we encountered at the prototype stage were taken into account. Also, this version turned out to be expandable and now it is possible to develop it further. For example, the process of turning the ship: if we just turn the ship, it will look boring, as if it is strung on a rod that rotates it. However, add a slight roll of the nose in the direction of rotation, and this process becomes much more attractive.

Also now synchronization of the intermediate positions of the ship is realized to the minimum. As soon as we fly to the destination, we synchronize the data with the server. So far, the difference in the final position on the server and the client diverges by a very small amount, but if it increases, there are many ideas about how to rotate this synchronization smoothly, without jerks and ship “jumps” in space. But I will tell about it already probably another time.

Source: https://habr.com/ru/post/253711/


All Articles