Combat components: where weapons live
Instead of piling weapon code onto the character, it goes in a component. The combat component keeps a
map from tag to weapon (Player.Weapon.Axe → the axe actor) and remembers which one is equipped.
Abilities reach it through small helper functions, so an ability can say "get my combat component, get the axe" without
caring which character it's on.
The component ladder
| Class | Adds |
|---|---|
UPawnExtensionComponentBase | GetOwningPawn<T>() / GetOwningController<T>(): typed access to whoever owns the component |
UPawnCombatComponent | The weapon map, register / get / currently equipped. Shared by heroes and enemies |
UHeroCombatComponent | GetHeroCarriedWeaponByTag: the same lookup, returned as AWarriorHeroWeapon* |
Why a component and not more code on the character? Composition: combat logic is reusable on any pawn (enemies
get a UPawnCombatComponent too), the character class stays small, and abilities can find combat stuff
without knowing the character's class.
The extension base: your templates assignment, for real
template <class T>
T* GetOwningPawn() const
{
static_assert(TPointerIsConvertibleFromTo<T, APawn>::Value, "'T' must be derived from APawn");
return CastChecked<T>(GetOwner());
}
APawn* GetOwningPawn() const { return GetOwningPawn<APawn>(); } // non-template overload
- Everything from the templates page, including the
static_assert. - Two functions with the same name is an overload:
GetOwningPawn()with no angle brackets calls the plain version, which just forwards to the template withAPawn. A convenience so most callers don't have to type<APawn>. protected: only the component and its subclasses can call these. Outsiders should go through the pawn.meta=(BlueprintSpawnableComponent): lets you add it in the Blueprint editor's Add Component list.
The weapon map
private:
TMap<FGameplayTag, AWarriorWeaponBase*> CharacterCarriedWeaponMap;
void UPawnCombatComponent::RegisterSpawnedWeapon(FGameplayTag InWeaponTagToRegister,
AWarriorWeaponBase* InWeaponToRegister, bool bRegisterAsEquippedWeapon)
{
checkf(!CharacterCarriedWeaponMap.Contains(InWeaponTagToRegister), TEXT("...already added..."));
check(InWeaponToRegister);
CharacterCarriedWeaponMap.Emplace(InWeaponTagToRegister, InWeaponToRegister);
if (bRegisterAsEquippedWeapon) CurrentEquippedWeaponTag = InWeaponTagToRegister;
}
AWarriorWeaponBase* GetCharacterCarriedWeaponByTag(FGameplayTag InWeaponTagToGet) const
{
return CharacterCarriedWeaponMap.FindRef(InWeaponTagToGet);
}
TMap<Key, Value>: a dictionary. Look something up by its key, fast, without looping. A gameplay tag makes a good key: "give me whatever is registered asPlayer.Weapon.Axe".EmplacevsAdd:Emplacebuilds the entry in place instead of building it then copying it in. With a tag and a pointer the difference is negligible. It's a habit.FindRefreturns a copy of the value, or a default (herenullptr) if the key isn't there. PlainFindreturns a pointer to the value inside the map (so here a pointer to a pointer). For a map of pointers,FindRefis the simple one.checkfon registering twice: a programming mistake, so crash loudly (check vs ensure).- Who calls it?
GA_Shared_SpawnWeapon(Blueprint) spawns the axe and callsRegisterSpawnedWeapon, which isBlueprintCallablefor exactly that reason.
CharacterCarriedWeaponMap is a raw pointer container with no UPROPERTY(), your mock Q3
again. Why it works today: the weapon is an actor in the level, and the level keeps its actors alive, so the GC
never collects it out from under you. Where it breaks: when a weapon is destroyed (enemy dies, weapon dropped), a
UPROPERTY reference would be cleared to nullptr for you. This raw one keeps the old address, a
dangling pointer. The fix is one line:
UPROPERTY()
TMap<FGameplayTag, TObjectPtr<AWarriorWeaponBase>> CharacterCarriedWeaponMap;
Worth asking your teacher about. "It works because the level owns the actor, but it isn't safe after Destroy" is a very good answer.
Helpers on the ability
// UWarriorGameplayAbility (any character)
UPawnCombatComponent* GetPawnCombatComponentFromActorInfo() const
{
return GetAvatarActorFromActorInfo()->FindComponentByClass<UPawnCombatComponent>();
}
// UWarriorHeroGameplayAbility (hero only)
AWarriorHeroCharacter* GetHeroCharacterFromActorInfo()
{
if (!CachedWarriorHeroCharacter.IsValid())
CachedWarriorHeroCharacter = Cast<AWarriorHeroCharacter>(CurrentActorInfo->AvatarActor.Get());
return CachedWarriorHeroCharacter.IsValid() ? CachedWarriorHeroCharacter.Get() : nullptr;
}
- "FromActorInfo": an ability doesn't have a fixed owner variable. It reads who it's running on from its actor
info (the owner/avatar pair you set with
InitAbilityActorInfo, GAS setup). FindComponentByClass<T>searches the actor's components for the first one that is aT. AUHeroCombatComponentcounts, because it's a subclass. That's why the shared ability works for heroes and enemies alike.- Caching in a
TWeakObjectPtr: cast once, remember it, and reuse it on later calls. Weak, so the ability never keeps the character alive, and if the character is destroyed,IsValid()says so instead of dangling (GC page). - Why these hero getters aren't
const: they write to the cache, so they change the object.constwould be a compile error. (The C++ escape hatch is marking the cachemutable, which is the standard trick for "caching doesn't count as changing".) See const. BlueprintPure: the Blueprint abilities (GA_Hero_EquipAxe) call these as pure nodes, with no exec pins.
GetHeroCombatComponentFromActorInfo()callsGetHeroCharacterFromActorInfo()->without a null check, but that getter can returnnullptr(e.g. if this ability were ever put on an enemy). Then it's a crash.HeroCombatComponentis inCategory = "Camera": copy-paste from the camera components above it, so it shows under Camera in the Details panel.GetHeroCombatComponent()is aFORCEINLINEgetter in apublic:section added at the end of the class. That's fine. It's why the ability can reach aprivatecomponent.
Quiz yourself
Why put weapon tracking in a component instead of on the character?
Reusable on any pawn (heroes and enemies), keeps the character class small, and abilities can find it without knowing the character's class.
What's an overload, and why does GetOwningPawn() have one?
Two functions with the same name but different forms. The plain GetOwningPawn() forwards to GetOwningPawn<APawn>() so callers who just want an APawn don't type the angle brackets.
TMap::FindRef vs TMap::Find?
FindRef returns a copy of the value (or a default like nullptr if missing). Find returns a pointer to the value stored in the map (or nullptr).
The weapon map has no UPROPERTY. Why does it work today, and when would it break?
The level keeps spawned actors alive, so GC won't collect them. But when a weapon is destroyed, a raw pointer isn't cleared, so it dangles. UPROPERTY() TMap<FGameplayTag, TObjectPtr<AWarriorWeaponBase>> fixes it.
Why does FindComponentByClass<UPawnCombatComponent> find the hero's UHeroCombatComponent?
It finds the first component that is a UPawnCombatComponent, and subclasses count.
Why cache the hero character in a TWeakObjectPtr rather than a raw pointer?
Weak never keeps the character alive and reports when it's gone (IsValid()), instead of leaving a dangling pointer.
Why can't GetHeroCharacterFromActorInfo() be const as written?
It writes to the cache member, which changes the object. Marking the cache mutable is the usual way to allow caching inside a const function.
Where does an ability get "who am I running on" from?
Its actor info (CurrentActorInfo): avatar actor, owner, player controller and ASC, set up by InitAbilityActorInfo.