Writing a motion component for RTS in Unreal engine 4

    image

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

    Objective: to implement the process of moving a spaceship on a plane.

    Conditions of the problem:
    • The ship has maximum speed, turning speed and acceleration speed. These parameters set the dynamics of the ship.
    • You must use Navigtaion Volume to automatically search for obstacles and lay a safe path.
    • There should not be constant synchronization of position coordinates through the network.
    • We can start the movement from different current speed states.
    • Everything should be native to the Unreal engine 4 architecture.

    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.

    The first task. Search for the best way

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

    In turn, UPawnMovementComponent - the successor of UNavMovementComponent , which adds to its structure FNavProperties - these are navigation parameters that describe this 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 that spans it. We ship to ship from one card to another point, and that's what happens inside the UE4:

    scheme41.jpg

    1) APawn , finds within himself ShipAIController (in our case it's just the heir AIController , which has a single method) and invokes a search method we created the path.
    2) Inside this method, we first prepare a request for the navigation system, then send it and get control points of movement.

    TArray AShipAIController::SearchPath(const FVector& location)
    {
        FPathFindingQuery Query;
        const bool bValidQuery = PreparePathfinding(Query, location, NULL);
        UNavigationSystem* NavSys = UNavigationSystem::GetCurrent(GetWorld());
        FPathFindingResult PathResult;
        TArray 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 the APawn ' list in a format convenient for us ( FVector ). Further, the movement process starts.
    In essence, it turns out like this: APawn has a ShipAIController in it , which, at the time of calling PreparePathfinding (), calls APawn and receives the UShipMovementComponent , inside which it finds FNavProperties , which it passes to the navigation system to find the path.

    The second task. Movement to the end point.

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

    1) We call the method of starting the movement - 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 Pawn'ov lack AIController , they are only on the server. Therefore, when the client calls the method of sending the ship to a new location, we must complete all the miscalculations on the server. In other words, the server will be busy finding the path for each ship. Because it is AIController that works with the navigation system.
    2) After inside the CommandMoveTo () method, we found a list of control points, we call the following to start 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 does not have any control points includes the first coordinate passed to him in the list of control points and “starts the engine”, starting the movement. At this moment, on the server, through timers, we start sending the remaining intermediate and final points of our path:

    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, while the ship starts to accelerate and move to the first control point of its path, it gradually receives the rest and puts them into its array. This allows us to unload the network and stretch the sending of data in time, distributing the load on the network.

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

    scheme3.png

    • We can fly to the destination point without limiting ourselves in acceleration even to the maximum speed and at the same time we will have enough turning speed to fit into the turn and arrive at the place.
    • Given our speed, we will fly too fast, so we will try to fly to the destination at a low speed, and when the bow of the ship points clearly in its direction, we will try to accelerate to maximum speed
    • If the path takes more time than stupidly turn around and fly in a straight line, then we will go the simple way.

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

    The only point worth mentioning is that in each iteration of the rotation of the ship you need to remember how much we have already 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, first we try to fly at full speed, then at reduced speed (now we are working at medium speed), and if none of these options fit, then the ship just needs to turn and fly.

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

    So, now the most important thing about all this is our component of the UShipMovementComponent movement . It is worthwhile to realize that the components of these types are motors. Their function is to give gas forward and rotate the object. They don’t think about what logic an object should move, they don’t think about what state the object is in. They are only responsible for the actual movement of the object. For stuffing fuel and shift in space. The logic of working with the UMovementComponent and its descendants is as follows: we are in thisTick ​​() , we make all our mathematical calculations related to the parameters of our component (speed, maximum speed, turning speed), after which we set the parameter UMovementComponent :: Velocity to a value that is 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 occurs.

    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 conditions that appear here. They are necessary in order to combine different processes of movement. We can, for example, slow down (because we need to switch to medium speed for maneuver) and turn towards a new destination. In the motion component, we use them only to evaluate work with speed: do we need to continue to set speed, or its decrease, etc. All the logic related to transitions from one state of movement to another, as I said, happens in AShip : for example, we go at maximum speed, and we change the destination, and to achieve it we need to reset the speed to medium.

    And the last two pennies about AcceptedRotator . This is our turn of the ship in this teak. In tick AShipwe 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 indicates what value the rotation of the ship should be in order to look at the destination. And in this method, we evaluate whether the ship is looking at its destination? If he looks, then we will return such a result, which means that 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 strive to rotate. Otherwise, we take the rotation of the ship and interpret it taking into account the DeltaTime and the speed of rotation of the ship. Then we apply this rotation in the current tick, when calling UMovementComponent :: MoveUpdatedComponent .

    Prospects


    It seems to me that this reincarnation of the UShipMovementComponent takes into account all the problems that we encountered at the prototype stage. Also, this version turned out to be expandable and now there is an opportunity to develop it further. For example, the process of turning a 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, the synchronization of intermediate positions of the ship is implemented to a minimum. As soon as we reach 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, however, if it increases, then there are many ideas on how to crank this synchronization smoothly, without jerking and "jumping" ships in space. But I’ll probably tell you about this another time.

    Also popular now: