Combat components: where weapons live

Commits c7f1bc6, fa176d1, ea95f4b · UPawnExtensionComponentBase, UPawnCombatComponent, UHeroCombatComponent, UWarriorHeroGameplayAbility · 10 min

In one breath

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

ClassAdds
UPawnExtensionComponentBaseGetOwningPawn<T>() / GetOwningController<T>(): typed access to whoever owns the component
UPawnCombatComponentThe weapon map, register / get / currently equipped. Shared by heroes and enemies
UHeroCombatComponentGetHeroCarriedWeaponByTag: 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

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);
}
Spot it: this map has no UPROPERTY

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;
}
Small things in your code

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.