Hello, and thanks for stopping by for the this entry in the Dandelion Digest series! The goal of this series is to provide visibility into Project Dandelion, the side project that I’m working on, and share some code samples so people can get a better idea of my skills and general approach.
Here’s what you can expect:
Want to see some of my more design oriented work from last year? Check out the prior blog entry. If you’d like to see more technical content like this, check out my most recent entry!
Also, all of the source code for headers in this post includes 2 versions. One with the multi-line docstrings included, and another with all of these docstring stripped out. I strongly believe in the value in documenting the intended use of each member/parameter and consider it a personal requirement I hold myself to before submitting for a code review. Typically, I rely on the code folding features in my IDE to keep the code readable with the docstrings in place, but the embedded snippet viewer I used does not include code folding features. If you’d like to pull/look at the source code from the GitHub repo, please contact me directly using the email button below.
Overview of Development Approach and Rationale
I started Project Dandelion using the Top Down C++ template from Unreal. My first development goal in this iteration was to move the gameplay logic for player movement out of the PlayerController and into a Gameplay Ability (GA).
Generally speaking, it is not recommended to implement player movement in a GA because MovementComponents already handle the networking and replication needed to maintain and correct the positions of players. A naive implementation of player movement using GAs could potentially require redundant networking traffic since for most games, player movement input tends to be continuous, not discrete.
A major reason why I elected to ignore this recommendation is because I do not currently plan for the game to be networked. I feel a major challenge of side projects is getting your game to playable state and adding multiplayer to a game greatly increases the complexity and therefore the friction to getting your game playable. Additionally, I do not have access to a team that can assist in testing networking edge cases, which would again increase the friction to getting the game into the hands of real play testers.
If I were to later decide to make my game multiplayer, the movement input for top down gameplay can be implemented as discrete inputs, thereby avoiding the concerns introduced by continuous input.
The major motivations for moving player movement into a GA are that I want to take advantage of the GA blocking/canceling based on Gameplay Tags features of the Gameplay Ability System (GAS). I also believe the stateful nature of GAs and robust callbacks will make it easier to implement more complex movement behaviors in the future.
The other development goal in this iteration was to create a generic, data-driven way to map Input Actions (IAs) to Gameplay Abilities so that I could better maintain a layer of abstraction between the two systems.
Gameplay Footage: Player Movement
Above, you can see the player movement behavior. While it imitates the behavior of the Top Down template project, all of the behavior is implemented using Gameplay Abilities and Gameplay Cues.
Details of Player State Class
The custom ADandelionPlayerState class is relatively simple. It is the owner of the player’s AbilitySystemComponent (ASC) and implements the IAbilitySystemInterface. The Player State is the owner in this project since I would like for the ASC to persist beyond the destruction of the player’s Pawn so that I can better control the behavior of the ASC if the player’s Pawn gets destroyed on death/respawn or gets switched out as a result of transforming or switching characters. It also defines DefaultGameplayAbilitySets and a method for granting these sets of GAs when the ASC is initialized.
One particular style choice I’d like to call out is that I generally opt to keep properties and helper methods private until otherwise needed. I believe this helps to simply the interface for other developers if they need to access the class or inherit from it in the future and if the access level does not prove to be sufficient, it can always be changed. The other style choice I’d like to point out is in the implementation of InitAbilitySystem(). In general, I try to avoid nesting in if and for loops if possible by checking for invalid states and early-outing before proceeding with the actual intended behavior of the function. I also make extensive use of logging and ensures when invalid states are encountered to assist with debugging.
DandelionPlayerState.h
DandelionPlayerState.h No Docs
DandelionPlayerState.cpp
Details of Player Controller Class
The custom ADandelionPlayerController class is also relatively simple. It defines some DefaultInputMappingContexts to be applied when the InputComponent gets setup. It also creates a required UPathFollowingComponent that will be needed to work with the Nav System and initializes it once the ADandelionPlayerController possesses a Pawn. Finally, it overrides StopMovement(). This is a method that is typically used to tell the Nav System to stop whatever pathing request is ongoing for a given controller, but this particular controller also uses the MovementInputVector to handle the case where the player follows the cursor while the movement button is held down. The overridden StopMovement() consumes the current MovementInputVector to ensure that all player movement stops when StopMovement() is called.
I don’t think there are any particularly interesting style choices to highlight here that haven’t already been called out. I did take advantage of the custom log category that was already defined by the template. I often use logging when debugging, and having custom log categories makes it easier to filter the logging output.
DandelionPlayerController.h
DandelionPlayerController.h No Docs
DandelionPlayerController.cpp
Details of Player Character Classes
The implementation of the Character Player class involves a base class to represent both the Player and the AI Characters, and an inheriting class for the player’s Character.
As mentioned above, the ADandelionCharacter is an abstract base class for both Player and AI Characters. It implements the IAbilitySystemInterface, but the implementation is marked as PURE_VIRTUAL, requiring inheritors of the class to implement the method. This is because for players, the ASC will live on the Player State, while for AI, it will likely live on the Character itself. relatively simple. Otherwise, it just sets some default values for the Character’s capsule, camera rotation settings, and movement settings.
I’ve called out for myself a few TODOs for some of the capsule and movement values being set directly in code instead of in a method that is more data driven. I’ve left those details in for now, but will likely return to them in the future.
DandelionCharacter.h
DandelionCharacter.h No Docs
DandelionCharacter.cpp
As mentioned above, the ADandelionPlayerCharacter is class that specifically represents Player Characters. It implements the IAbilitySystemInterface by getting the PlayerState and finding the ASC on the PlayerState. It also instantiates and configures the USpringArmComponent and the UCameraComponent with default values. It also defines a reference to the InputActionGameplayAbilityMappings which define the mapping from InputActions to GameplayAbilities as well as a list of DefaultGameplayAbilitySets that should be applied when the ASC get set up after this Character gets possessed. This is so that the PlayerState can define AbilitySets that should be applied regardless of which Character is possessed, and the Character can define AbiltySets that should be applied specifically for that character.
Finally, it overrides SetupPlayerInputComponent() which is the method that binds OnAbilityInputTriggered() and OnAbilityInputCompleted() to the appropriate TriggerEvents on the InputAction that is defined in the InputActionGameplayAbilityMappings. OnAbilityInputTriggered() and OnAbilityInputCompleted() will forward the received inputs to the ASC using the InputID that was defined in the InputActionGameplayAbilityMappings. When an AbilitySet is applied, the AbilitySet has the logic to ensure that the correct InputID is set when a GameplayAbility with a mapping in the InputActionGameplayAbilityMappings is granted.
I’ve called out for myself in a TODO since I am not capturing the outputs of BindAction(), and so may be leaving dangling bindings on the EnhancedInputComponent when an ADandelionPlayerCharacter gets destroyed. I figure it’ll be simple enough to address if it actually becomes an issue.
DandelionPlayerCharacter.h
DandelionPlayerCharacter.h No Docs
DandelionPlayerCharacter.cpp
Details of Classes for Mapping Input Actions to Gameplay Abilities
Now that we’ve covered the PlayerState, PlayerController, and Character classes, we can start to go into greater detail of the other classes involved in the mapping of InputActions to GameplayAbility. At a high-level, the flow of a particular player input to the activation of a given GameplayAbility so far looks like:
The UInputActionGameplayAbilityMappings class contains a TSet of FInputActionGameplayAbilityMappingData. UInputActionGameplayAbilityMappings also defines some convenience getter functions and an override for PostEditChangeProperty().
The FInputActionGameplayAbilityMappingData struct represents an individual mapping from a GameplayAbility to an ETriggerEvent on a target InputAction. This struct also contains the InputID which gets automatically generated in the Editor by logic in the PostEditChangeProperty() override in UInputActionGameplayAbilityMappings. This is because the actual value of the InputID is not important, just that the InputID bound to the InputAction’s ETriggerEvent and the InputID bound the GA on the ASC are matching. FInputActionGameplayAbilityMappingData defines an IsValid() check to ensure that no default values are set that would prevent a well-formed mapping. It also provides some equality operators and a GetTypeHash() implementation to ensure accurate behavior in TSets and other hashable container types.
One of the limitations with this current implementation is that the InputIDs only get automatically generated within the scope of an individual UInputActionGameplayAbilityMappings. This means that there can effectively only be one instance UInputActionGameplayAbilityMappings. Otherwise, automatically generated IDs in the second instance would overwrite/collide with the InputIDs and corresponding bindings defined by the first instance. At least right now, I do not see a pressing need to be able to support this behavior, though I may need to return to this if there is an actual gameplay need. Also, I typically implement Data Validation methods for classes that are supposed to populated in Editor by other team members (like designers), but I feel right now that it’s unlikely that I’ll forget or misunderstand the requirements and intended use case of the class, so I’ve been a little lazy and opted not to implement those methods. Also I just want to point out that in InputActionGameplayAbilityMappings.cpp, I used some #pragma region directives to enable code folding. Unfortunately, folding isn’t available on the online viewer, but outside of this context and possibly within a code review tool, I don’t really think there’s any reason to be viewing the code outside an IDE.
InputActionGameplayAbilityMappings.h
InputActionGameplayAbilityMappings.h No Docs
InputActionGameplayAbilityMappings.cpp
The UDandelionAbilitySet class contains a TSet of TSubclassOf<UGameplayAbility> and implements methods for giving these abilities to a particular ASC. In particular, the GiveAbility() helper method on UDandelionAbilitySet is the method that binds the InputID to a particular GameplayAbility if one is defined within the provided UInputActionGameplayAbilityMappings.
Debatably, it might be better to move the GiveAbility() method in this class and possibly even the GiveAbilities() method to a custom ASC that overloads the GiveAbility() method with the implementation here. Looking over the code now, it actually feels like it makes more sense to keep the UDandelionAbilitySet “dumb” since it’s just a DataAsset and move the implementations, but I also think I’m splitting hairs at this point and this particular location might not be ideal, but also isn’t totally invalid or blocking to further development. Also, I totally forgot to make the Abilities UPROP private. I’ll probably update this file again in my own time.
DandelionAbilitySet.h
DandelionAbilitySet.h No Docs
DandelionAbilitySet.cpp
At this point, we have now called TryActivateAbility(). Assuming that this actually succeeds, we will have now entered ActivateAbility() on the GameplayAbility. At this point, the behavior needed to eventually reach EndAbility() is up to the implementation of that particular GameplayAbility. If a regular GameplayAbility is the one that was mapped by the UInputActionGameplayAbilityMappings, then the GameplayAbility will not receive any further callbacks unless it directly implements support for it.
I also implemented a UInputActionGameplayAbility (IAGA) class that defines a GameplayAbility that should continue to receive callbacks and even potentially perform basic behaviors like call EndAbility() or CancelAbility() in response to these callbacks. Specifically, in ActivateAbility(), the UInputActionGameplayAbilityMappings are searched for the mappings matching this GameplayAbility and InvokeTriggeredInputAction() is bound to the InputAction’s Triggered ETriggerEvent if the original activation trigger defined in the UInputActionGameplayAbilityMappings was the Started ETriggerEvent. This is to support behaviors that want InputActions that will activate and perform behaviors once the InputAction in question starts to potentially be triggered, and to alter their behavior in response to the InputAction actually meeting all of the Triggered ETriggerEvent requirements. InvokeTriggeredInputAction() will broadcast to both a native and a dynamic delegate that additional behaviors can subscribe to.
Additionally, in response to the InputPressed() callback, an IAGA can be configured to do nothing, restart the GameplayAbility by calling EndAbility() or CancelAbility() before calling TryActivateAbility(), or to call the HandleInputPressed() method that can be overridden by a native child class or in BP. Similarly, in response to InputReleased(), an IAGA can be configured to do nothing, call EndAbility() or CancelAbility(), or to call an overridable HandleInputReleased() method. As one can see, there’s several different flows by which EndAbility() or CancelAbility() can be reached. I should probably make a flow chart or something for this.
I’ll breakdown the custom base UDandelionGameplayAbility class first before going into further depth on the UInputActionGameplayAbility class.
The UDandelionGameplayAbility class provides a few convenience functions like a templated TryCastAvatarActorFromActorInfo() method that does pretty much exactly what it describes and a GetASCOwnerActor() that retrieves a reference to the Actor that owns the ASC that this GA was activated on. Additionally, it overrides most of the major GameplayAbility callbacks with Verbose logging statements. Although the game is not currently networked, it still uses the ActivationPredicitonKey in the FGameplayAbilityActivationInfo parameter as these are unique to each activation of the GA and are (and already have been) extremely helpful in debugging the behavior of a given GameplayAbility. Finally, it implements methods for restarting a GameplayAbility by calling EndAbility() or CancelAbility() based on the bCancelAbility parameter and an overridable CanRestartAbility() that checks that the GameplayAbility is active, can be activated again, and meets the current cost/tag requirements.
One particular thing to note is that one of the RestartAbility() log statements is set to VeryVerbose as it has the potential to spam the log every frame for IAGAs when the corresponding input is held down and triggered every frame.
DandelionGameplayAbility.h
DandelionGameplayAbility.h No Docs
DandelionGameplayAbility.cpp
The UInputActionGameplayAbility class has been mostly described above as part of explaining the typical flow from ActivateAbility() to EndAbility(), but there are a few more details worth mentioning.
First, the ResponseOnInputPressed and ResponseOnInputReleased properties use a custom enum, EInputActionGameplayAbilityResponse, to define the behavior of the IAGA when InputPressed() and InputReleased() are called.
Secondly, I’m not in love with the fact that the UInputActionGameplayAbilityMappings are being searched for every call to ActivateAbility() to correctly bind InvokeTriggeredInputAction(). Potentially, I could cache the InputAction that corresponds to the IAGA, but I don’t love the idea of breaking the layer of abstraction that currently exists between InputActions and the actual GameplayAbility logic.
Finally, I also don’t love that the InvokeTriggeredInputAction() method and HandleInputPressed() method are both effectively handlers for the same event and will be raised together pretty much every time, but are bound to callbacks from two different sources: the EnhancedInputComponent and the ASC, respectively. I feel like the issue is that HandleInputPressed() makes it difficult to retrieve the FInputActionValue since its purpose is to help abstract away the input system. I don’t think that it will be an issue right now, since I don’t plan to make use of any 1 or 2-axis driven input, and therefore, really only need to know if the InputAction was triggered or not, but I do feel like this implementation doesn’t handle those particular use cases very cleanly. Possibly something to iterate on in the future.
InputActionGameplayAbility.h
InputActionGameplayAbility.h No Docs
InputActionGameplayAbility.cpp
Details of Movement Ability Task Classes
Ultimately, the objective of all of this boilerplate is to eventually implement a GA that will cause the player to follow the cursor location while the input is held down and will play a VFX and use the Nav System to path the player to the cursor location when the button is released. The movement of the character is accomplished through a few custom AbilityTasks. The UAbilityTask_MoveCharacterToLocation is the base class that UAbilityTask_MoveCharacterToCursor and UAbilityTask_MoveCharacterToPoint derive from to implement pathing using the Nav System with appropriate callbacks. The UAbilityTask_FollowCursor is a separate class since the cursor following movement behavior differs from the other AbilityTasks.
The custom UAbilityTask_MoveCharacterToLocation class contains the bulk of the important implementation details for moving the character. It provides a BP accessible callback, OnMoveComplete that is used to indicate that the movement request to the NavSystem has finished and what the actual result of the movement request was. It disables ticking since it depends entirely on callbacks from the Nav System to maintain its state. The InitializeBaseMoveTask() helper method caches some transient references when the AbilityTask is constructed. When canceled externally, the AbilityTask immediately calls StopMovement(). When Activate() is called, it stops any current player movement, starts a new path request using SimpleMoveToLocation(), and binds HandleOnRequestFinished() using a lambda on to the OnRequestFinished callback on the UPathFollowingComponent. HandleOnRequestFinished() just checks that the PathRequestID matches and that the FPathFollowingResult was a success before broadcasting OnMoveComplete. When OnDestroy() is called, this class removes the binding of HandleOnRequestFinished() from the OnRequestFinished callback, if valid.
Some potential points of improvement I can see are that the current implementation of the task assumes that the GA that uses the AbilityTask will call EndTask() or ExternalCancel(), even if the move request was a success. This gives the GA more flexibility in terms of how it decides to respond to the move request completing, but I think that it also creates a stronger coupling between the GA and AbilityTask classes as the GA now needs to be aware of this particular implementation detail. The other point of improvement I can see is that OnMoveComplete is only broadcast on success, but it could broadcast regardless of the FPathFollowingResult so that the GA can have greater control over how it handles the callback. I don’t think either is a critically blocking issue right now, but a few TODOs.
AbilityTask_MoveCharacterToLocation.h
AbilityTask_MoveCharacterToLocation.h No Docs
AbilityTask_MoveCharacterToLocation.cpp
The UAbilityTask_MoveCharacterToCursor class is comparatively simple. It just has the standard static constructor and when Activate() is called, it sets the target position to be the current position under the cursor which it finds using the GetPositionUnderCursor() helper function. It’s important to do it at this point, as initialization of the AbilityTask is not the same as activation of the AbilityTask. It otherwise uses all the same callback/logic as the base class UAbilityTask_MoveCharacterToLocation.
One point of potential improvement would be to make the trace channel used in determining the point under the cursor a parameter you can pass into the AbilityTask.
AbilityTask_MoveCharacterToCursor.h
AbilityTask_MoveCharacterToCursor.h No Docs
AbilityTask_MoveCharacterToCursor.cpp
The UAbilityTask_MoveCharacterToPoint class is even simpler. It’s pretty much identical to the base class UAbilityTask_MoveCharacterToLocation, but also includes a parameter in its static constructor to set the target location.
AbilityTask_MoveCharacterToPoint.h
AbilityTask_MoveCharacterToPoint.h No Docs
AbilityTask_MoveCharacterToPoint.cpp
The UAbilityTask_FollowCursor class is a separate implementation from the prior three AbilityTasks as it uses AddMovementInput() instead of the Nav System to move the player character. It also has an OnMoveComplete callback that is raised when the player character reaches a configurable stopping distance set in the static constructor. When ExternalCancel() and Activate() are called, it calls StopMovement() on the character. Finally, the task uses the TickTask() function to update the target location based on the cursor location and then checks if the XY distance of the character from the target point is below the StopDistance threshold. If so, it calls OnMoveComplete and ends the task. Otherwise, it uses AddMovementInput() to move the character towards the cursor hit point.
Two points of improvement that I can see here are that first, this task calls EndTask() on itself, unlike the other three AbilityTasks, so the lifespan management of the tasks is inconsistent. The other issue is that OnMoveComplete won’t get called on these tasks right now because the camera is currently a follow camera instead of a free camera. Whenever the player moves, then the hit point of the cursor will also move, so the character will continue moving until it hits a wall or something. It’s just a little odd to have the callback, but I think it’s necessary in case I do elect to implement a free camera or bounding boxes on the follow camera.
AbilityTask_FollowCursor.h
AbilityTask_FollowCursor.h No Docs
AbilityTask_FollowCursor.cpp
Details of the Player Movement GA
Finally, with all of these building blocks in place, we can implement the UIAGA_PlayerMove class. This IAGA has two phases. The first phase is while the mouse button is held down, follow the cursor using a UAbilityTask_FollowCursor. The second phase is when the mouse button is released, use the nav system to move to the point that was under the cursor when released using a UAbilityTask_MoveCharacterToCursor and use a GameplayCue to play a VFX indicating the targeted move location.
It sets some default values in the custom constructor. Specifically, that it should restart the ability using CancelAbility() when the input is pressed, and should use the custom HandleInputReleased() callback when the input is released. The CanRestartAbility() check enforces that the GA can only be restarted if it is currently in Phase 2. This makes it so an ongoing move request using a UAbilityTask_MoveCharacterToCursor can be canceled and so that the GA can go back to phase 1. When the ability is activated, it stops any current player movement and then starts a UAbilityTask_FollowCursor as per phase 1 behavior. If CancelAbility() gets called, it will cancel the FollowCursorAbilityTask and MoveCharacterToCursorAbilityTask if they are valid and active using ExternalCancel(). When EndAbility() is called, it cleans up the FollowCursorAbilityTask and MoveCharacterToCursorAbilityTask using EndTask(), but raises a warning as both of these tasks should already have been cleaned up at this point. The overridden HandleInputReleased_Implementation() method cancels the FollowCursorAbilityTask and starts the MoveCharacterToCursorAbilityTask, binding HandleOnMoveComplete() to the OnMoveComplete callback. If the MoveVFXCueTag is valid, it also executes the GameplayCue with the TargetLocation from the MoveCharacterToCursorAbilityTask set as the Location in the Cue Params. Finally, when HandleOnMoveComplete() is called, it checks to ensure that the GA is in phase 2, throwing an error and canceling the GA if it is not. If the path following result was not a success, cancel the MoveCharacterToCursorAbilityTask and this GA, and if it is a success, then end the MoveCharacterToCursorAbilityTask and this GA.
One of the things I don’t love about this current implementation is that the CanActivateAbility() method gets called every frame during phase 1 of this GA, which is not ideal from a perf perspective, though probably not critical since there’s only one player character to trigger it. Secondly, when the FollowCursorAbilityTask calls OnMoveComplete, it just directly calls EndAbility(). It might be preferable to issue a logging statement at this point since under the current implementation, this case should never be reached. Finally, I don’t like using AddDynamic in native code to subscribe to events since it becomes more difficult to manage the lifecycle of the bindings to those events. Although it should be impossible for the lifespan of the AbilityTasks to outlast the lifespan of the GameplayAbility and thereby create a null reference, I still think it’d be preferable to use the native/BP callback pattern in the AbilityTasks that I used in the UInputActionGameplayAbility class to represent the OnTriggeredInputAction event.
IAGA_PlayerMove.h
IAGA_PlayerMove.h No Docs
IAGA_PlayerMove.cpp
Roadmap of Future Development Goals
If you’ve made it this far, then thanks for reading! While by no means perfect, I hope it gives a bit of a picture into what I’ve been working on. Moving forward, the next development goals I’m going to work towards are as follows in this order: