【UE4】GameplayAbilityとAnimNotify/AnimNotifyStateを使ってコンボを作る

この記事はUnrealEngine 4(UE4)AdventCalender 2020の15日目の投稿記事です。

 

qiita.com

 

やることはタイトルの通りコンボをUE4で実装する方法の一つの紹介みたいな感じです。

今回のアドベントカレンダーでネタ被りをしてしまって申し訳ない・・・。

qiita.com

 

GameplayAbilityの概要やら導入について説明してくださっている先駆者の方々や公式のドキュメント

docs.unrealengine.com

okawari-hakumai.hatenablog.com

historia.co.jp

 

 GameplayAbilitySystemの概要と導入は上記の記事を参考にしてください。

 

アニメーションのアセットは下記のものを使用しました。

Dynamic Sword Animset:アニメーション - UE マーケットプレイス

 

また、このコンボ実装は公式のActionRPGプロジェクトを参考にさせてもらいました。

Action RPG:Epic コンテンツ - UE マーケットプレイス

 

AnimNotifyとAnimNotifyStateが存在するので、それについて軽く説明と使い方をまとめます。

docs.unrealengine.com

AnimNotifyとAnimNotifyStateはアニメーションでの通知イベントのクラスです。両者の違いは1フレームか複数フレームにまたがるかです。

 

AnimNotifyは挿入したフレームの際にフックするReceiveNotifyイベントが、

AnimNotifyStateはBegin、Tick、Endイベントが実装されています。

 

これを利用して入力受付の時間の調整などの処理を実装していきます。

 

プロジェクトはUE4.26のC++のBlankで作成しました。C++のThirdPersonでもいいと思います。

プラグインを有効化したあと、プロジェクトのBuild.csを編集します。

// Copyright Epic Games, Inc. All Rights Reserved.

using UnrealBuildTool;

public class MyProject4 : ModuleRules
{
    public MyProject4(ReadOnlyTargetRules Target) : base(Target)
    {
        PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
    
        PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "GameplayAbilities", "GameplayTags", "GameplayTasks" });

        PrivateDependencyModuleNames.AddRange(new string[] {  });

        // Uncomment if you are using Slate UI
        // PrivateDependencyModuleNames.AddRange(new string[] { "Slate", "SlateCore" });
        
        // Uncomment if you are using online features
        // PrivateDependencyModuleNames.Add("OnlineSubsystem");

        // To include OnlineSubsystemSteam, add it to the plugins section in your uproject file with the Enabled attribute set to true
    }
}

GameplayAbilitiesとGameplayTags、GameplayTasksをPublicDependencyModuleNamesに追加しました。 

 

次にキャラクターの実装をします。冒頭の記事を参考にC++で作成します。適宜自身のプロジェクトに置き換えてください。

 

MyCharacter.h

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "AbilitySystemInterface.h"
#include "MyAbilitySystemComponent.h"
#include "MyCharacter.generated.h"

class UMyGameplayAbility;

UCLASS()
class MYPROJECT4_API AMyCharacter : public ACharacter, public IAbilitySystemInterface
{
    GENERATED_BODY()

    /** Camera boom positioning the camera behind the character */
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = Camera, meta = (AllowPrivateAccess = "true"))
    class USpringArmComponent* CameraBoom;

    /** Follow camera */
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = Camera, meta = (AllowPrivateAccess = "true"))
    class UCameraComponent* FollowCamera;

public:
    // Sets default values for this character's properties
    AMyCharacter();

    /** Base turn rate, in deg/sec. Other scaling may affect final turn rate. */
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = Camera)
    float BaseTurnRate;

    /** Base look up/down rate, in deg/sec. Other scaling may affect final rate. */
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = Camera)
    float BaseLookUpRate;

protected:
    // Called when the game starts or when spawned
    virtual void BeginPlay() override;

    /** Called for forwards/backward input */
    void MoveForward(float Value);

    /** Called for side to side input */
    void MoveRight(float Value);

    /**
    * Called via input to turn at a given rate.
    * @param Rate  This is a normalized rate, i.e. 1.0 means 100% of desired turn rate
    */
    void TurnAtRate(float Rate);

    /**
    * Called via input to turn look up/down at a given rate.
    * @param Rate  This is a normalized rate, i.e. 1.0 means 100% of desired turn rate
    */
    void LookUpAtRate(float Rate);

    /** Handler for when a touch input begins. */
    void TouchStarted(ETouchIndex::Type FingerIndex, FVector Location);

    /** Handler for when a touch input stops. */
    void TouchStopped(ETouchIndex::Type FingerIndex, FVector Location);


public:   
    // Called every frame
    virtual void Tick(float DeltaTime) override;

    // Called to bind functionality to input
    virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override;

public:
    /** Returns CameraBoom subobject **/
    FORCEINLINE class USpringArmComponent* GetCameraBoom() const { return CameraBoom; }
    /** Returns FollowCamera subobject **/
    FORCEINLINE class UCameraComponent* GetFollowCamera() const { return FollowCamera; }

public:
    /*Ability System*/
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Abilities", meta = (AllowPrivateAccess = "true"))
        class UMyAbilitySystemComponent* AbilitySystem;

    UAbilitySystemComponent* GetAbilitySystemComponent() const
    {
        return Cast<UAbilitySystemComponent>(AbilitySystem);
    };


    /*Ability List*/
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Abilities")
        TArray<TSubclassOf<class UGameplayAbility>> AbilityList;

    virtual void PossessedBy(AController* NewController) override;

public:
    UFUNCTION(BlueprintCallable, Category = "Abilities")
        bool ActivateAbilitiesWithTags(FGameplayTagContainer AbilityTags, bool bAllowRemoteActivation = true);

    UFUNCTION(BlueprintCallable, Category = "Abilities")
        void GetActiveAbilitiesWithTags(FGameplayTagContainer AbilityTags, TArray<UMyGameplayAbility*>& ActiveAbilities);
};

MyCharacter.cpp

// Fill out your copyright notice in the Description page of Project Settings.


#include "MyCharacter.h"
#include "Camera/CameraComponent.h"
#include "Components/CapsuleComponent.h"
#include "Components/InputComponent.h"
#include "GameFramework/CharacterMovementComponent.h"
#include "GameFramework/Controller.h"
#include "GameFramework/SpringArmComponent.h"
//#include <AbilitySystemComponent.h>

// Sets default values
AMyCharacter::AMyCharacter()
{
    GetCapsuleComponent()->InitCapsuleSize(42.f, 96.0f);

    // set our turn rates for input
    BaseTurnRate = 45.f;
    BaseLookUpRate = 45.f;

    // Don't rotate when the controller rotates. Let that just affect the camera.
    bUseControllerRotationPitch = false;
    bUseControllerRotationYaw = false;
    bUseControllerRotationRoll = false;

    // Configure character movement
    GetCharacterMovement()->bOrientRotationToMovement = true; // Character moves in the direction of input...  
    GetCharacterMovement()->RotationRate = FRotator(0.0f, 540.0f, 0.0f); // ...at this rotation rate
    GetCharacterMovement()->JumpZVelocity = 600.f;
    GetCharacterMovement()->AirControl = 0.2f;

    // Create a camera boom (pulls in towards the player if there is a collision)
    CameraBoom = CreateDefaultSubobject<USpringArmComponent>(TEXT("CameraBoom"));
    CameraBoom->SetupAttachment(RootComponent);
    CameraBoom->TargetArmLength = 300.0f; // The camera follows at this distance behind the character  
    CameraBoom->bUsePawnControlRotation = true; // Rotate the arm based on the controller

    // Create a follow camera
    FollowCamera = CreateDefaultSubobject<UCameraComponent>(TEXT("FollowCamera"));
    FollowCamera->SetupAttachment(CameraBoom, USpringArmComponent::SocketName); // Attach the camera to the end of the boom and let the boom adjust to match the controller orientation
    FollowCamera->bUsePawnControlRotation = false; // Camera does not rotate relative to arm

    // Note: The skeletal mesh and anim blueprint references on the Mesh component (inherited from Character) 
    // are set in the derived blueprint asset named MyCharacter (to avoid direct content references in C++)

    AbilitySystem = CreateDefaultSubobject<UMyAbilitySystemComponent>(TEXT("AbilitySystem"));

}

// Called when the game starts or when spawned
void AMyCharacter::BeginPlay()
{
    Super::BeginPlay();
    
    if (AbilitySystem)
    {
        int32 inputID(0);
        if (HasAuthority() && AbilityList.Num() > 0)
        {
            for (auto Ability : AbilityList)
            {
                if (Ability)
                {
                    AbilitySystem->GiveAbility(FGameplayAbilitySpec(Ability.GetDefaultObject(), 1, inputID++));
                }
            }
        }
        AbilitySystem->InitAbilityActorInfo(this, this);
    }
}

void AMyCharacter::MoveForward(float Value)
{
    if ((Controller != nullptr) && (Value != 0.0f))
    {
        // find out which way is forward
        const FRotator Rotation = Controller->GetControlRotation();
        const FRotator YawRotation(0, Rotation.Yaw, 0);

        // get forward vector
        const FVector Direction = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::X);
        AddMovementInput(Direction, Value);
    }
}

void AMyCharacter::MoveRight(float Value)
{
    if ((Controller != nullptr) && (Value != 0.0f))
    {
        // find out which way is right
        const FRotator Rotation = Controller->GetControlRotation();
        const FRotator YawRotation(0, Rotation.Yaw, 0);

        // get right vector 
        const FVector Direction = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::Y);
        // add movement in that direction
        AddMovementInput(Direction, Value);
    }
}

void AMyCharacter::TurnAtRate(float Rate)
{
    // calculate delta for this frame from the rate information
    AddControllerYawInput(Rate * BaseTurnRate * GetWorld()->GetDeltaSeconds());
}

void AMyCharacter::LookUpAtRate(float Rate)
{
    // calculate delta for this frame from the rate information
    AddControllerPitchInput(Rate * BaseLookUpRate * GetWorld()->GetDeltaSeconds());
}

void AMyCharacter::TouchStarted(ETouchIndex::Type FingerIndex, FVector Location)
{
}

void AMyCharacter::TouchStopped(ETouchIndex::Type FingerIndex, FVector Location)
{
}

// Called every frame
void AMyCharacter::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

}

// Called to bind functionality to input
void AMyCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
    // Set up gameplay key bindings
    check(PlayerInputComponent);
    PlayerInputComponent->BindAction("Jump", IE_Pressed, this, &ACharacter::Jump);
    PlayerInputComponent->BindAction("Jump", IE_Released, this, &ACharacter::StopJumping);

    PlayerInputComponent->BindAxis("MoveForward", this, &AMyCharacter::MoveForward);
    PlayerInputComponent->BindAxis("MoveRight", this, &AMyCharacter::MoveRight);

    // We have 2 versions of the rotation bindings to handle different kinds of devices differently
    // "turn" handles devices that provide an absolute delta, such as a mouse.
    // "turnrate" is for devices that we choose to treat as a rate of change, such as an analog joystick
    PlayerInputComponent->BindAxis("Turn", this, &APawn::AddControllerYawInput);
    PlayerInputComponent->BindAxis("TurnRate", this, &AMyCharacter::TurnAtRate);
    PlayerInputComponent->BindAxis("LookUp", this, &APawn::AddControllerPitchInput);
    PlayerInputComponent->BindAxis("LookUpRate", this, &AMyCharacter::LookUpAtRate);
}

void AMyCharacter::PossessedBy(AController* NewController)
{
    Super::PossessedBy(NewController);

    AbilitySystem->RefreshAbilityActorInfo();
}

bool AMyCharacter::ActivateAbilitiesWithTags(FGameplayTagContainer AbilityTags, bool bAllowRemoteActivation)
{
    if (AbilitySystem)
    {
        return AbilitySystem->TryActivateAbilitiesByTag(AbilityTags, bAllowRemoteActivation);
    }
    return false;
}

void AMyCharacter::GetActiveAbilitiesWithTags(FGameplayTagContainer AbilityTags, TArray<UMyGameplayAbility*>& ActiveAbilities)
{
    if (AbilitySystem)
    {
        AbilitySystem->GetActiveAbilitiesWithTags(AbilityTags, ActiveAbilities);
    }
}

基本的にはThirdpersonのC++からパクってきてます。一部いらなさそうなやつは削除したりしてます。

 

キャラクターで使っている独自AbilitySystemComponentとGameplayAbilityを追加で作ります。

MyGameplayAbility.h

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "CoreMinimal.h"
#include "Abilities/GameplayAbility.h"
#include "MyGameplayAbility.generated.h"

/**
 * 
 */
UCLASS()
class MYPROJECT4_API UMyGameplayAbility : public UGameplayAbility
{
    GENERATED_BODY()
    
    UFUNCTION(BlueprintCallable, Category = "GameplayAbility")
    virtual void AddGameplayTags(const FGameplayTagContainer GameplayTags);

    UFUNCTION(BlueprintCallable, Category = "GameplayAbility")
    virtual void RemoveGameplayTags(const FGameplayTagContainer GameplayTags);

};

MyGameplayAbility.cpp

// Fill out your copyright notice in the Description page of Project Settings.


#include "MyGameplayAbility.h"
#include "AbilitySystemComponent.h"

void UMyGameplayAbility::AddGameplayTags(const FGameplayTagContainer GameplayTags)
{
    UAbilitySystemComponent* Comp = GetAbilitySystemComponentFromActorInfo();

    Comp->AddLooseGameplayTags(GameplayTags);
}

void UMyGameplayAbility::RemoveGameplayTags(const FGameplayTagContainer GameplayTags)
{
    UAbilitySystemComponent* Comp = GetAbilitySystemComponentFromActorInfo();
    Comp->RemoveLooseGameplayTags(GameplayTags);
}

MyAbilitySystemComponent.h

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "CoreMinimal.h"
#include "AbilitySystemComponent.h"
#include "MyAbilitySystemComponent.generated.h"

/**
 * 
 */

class UMyGameplayAbility;
class UGameplayAbility;

UCLASS()
class MYPROJECT4_API UMyAbilitySystemComponent : public UAbilitySystemComponent
{
    GENERATED_BODY()
public:
    UMyAbilitySystemComponent();

    void GetActiveAbilitiesWithTags(const FGameplayTagContainer& GameplayTagContainer, TArray<UMyGameplayAbility*>& ActiveAbilities);
    static UMyAbilitySystemComponent* GetAbilitySystemComponentFromActor(const AActor* Actor, bool LookForComponent = false);
};

MyAbilitySystemComponent.cpp

// Fill out your copyright notice in the Description page of Project Settings.


#include "MyAbilitySystemComponent.h"
#include "MyGameplayAbility.h"
#include "AbilitySystemGlobals.h"

UMyAbilitySystemComponent::UMyAbilitySystemComponent() {}

void UMyAbilitySystemComponent::GetActiveAbilitiesWithTags(const FGameplayTagContainer& GameplayTagContainer, TArray<UMyGameplayAbility*>& ActiveAbilities)
{
    TArray<FGameplayAbilitySpec*> AbilitiesToActivate;
    GetActivatableGameplayAbilitySpecsByAllMatchingTags(GameplayTagContainer, AbilitiesToActivate, false);

    for (FGameplayAbilitySpec* spec : AbilitiesToActivate)
    {
        TArray<UGameplayAbility*> AbilityInstances = spec->GetAbilityInstances();

        for (UGameplayAbility* ActiveAbility : AbilityInstances)
        {
            ActiveAbilities.Add(Cast<UMyGameplayAbility>(ActiveAbility));
        }
    }
}

UMyAbilitySystemComponent* UMyAbilitySystemComponent::GetAbilitySystemComponentFromActor(const AActor* Actor, bool LookForComponent)
{
    return Cast<UMyAbilitySystemComponent>(UAbilitySystemGlobals::GetAbilitySystemComponentFromActor(Actor, LookForComponent));
}

 

VisualStudioのビルドが通れば、C++側の実装は終わりになります。

 

UE4Editorに移り、AnimNotifyStateを作ります。

f:id:shiratori00:20201214112305p:plain

名前はNS_JumpSectionにしておきます。

グラフが開いたら、FunctionsでReceivedNotifyBegin/EndをOverrideで作ります。

また、VariablesにName型の変数を一つ追加します。ここでは名前をNextSectionNameとしました。

f:id:shiratori00:20201214183721p:plain

End側ではコンボ用のプロパティを初期化しています。

f:id:shiratori00:20201214183726p:plain

Start側ではMontageの次のセクションを初期化し、後述で作るプレイヤークラスのコンボ入力の受付可能フラグを立て、現在実行中のAnimNotifyStateクラスである自身をプレイヤークラスのプロパティにセットしています。

 

次に再生するアニメーションモンタージュを作成します。

名前はAM_Swordとします。

いい感じに攻撃モーションを並べて、先ほど作成したAnimNotifyStateを配置します。

f:id:shiratori00:20201214184335p:plain

f:id:shiratori00:20201214184341p:plain

今回はループで延々と攻撃が続けられるように二つの配置とNS_JumpSectionをそれぞれに配置していますが、ループせずに終わる場合は、最後のモーションには通知を置かないようにします。

 

AnimationBlueprintでは作成したアニメーションモンタージュを再生できるようにしておきます。

f:id:shiratori00:20201214184631p:plain

 

GameplayAbilityを作ります。名前はGA_Testとしておきます。

f:id:shiratori00:20201214185405p:plain

変数にAnimationMontageを追加します。名前はanimMontageで、デフォルトの値には先ほど作成したアニメーションモンタージュをセットしておきます。

やってることはAnimationMontageを再生開始しています。

 

最後に操作するキャラクタークラスを作成します。MyCharacterクラスを基底クラスとしたBlueprintクラスを作成します。名前はBP_Characterにしておきます。

VariablesのAbilities/AbilityListに先ほど作成したGameplayAbilityを追加します。これで実行時にAbilitySystemComponentに自動で登録されます。

また、変数にコンボ受付可能フラグのEnableComboPeriod、現在実行中のAnimNotifyStateクラスを格納するCurrentNotifyを追加します。

再生用にDoMeleeという関数を作成します。

f:id:shiratori00:20201214185811p:plain

 

DoMelee関数の中身です。

f:id:shiratori00:20201214190019p:plain

現在アニメーション通知が存在するなら後述するコンボ処理関数に処理を移します。

存在しない場合はニュートラル状態なので、新規でGameplayAbilityを実行します。

 

コンボ処理のJumpSectionForCombo関数の中身です。

f:id:shiratori00:20201214190719p:plain

処理としてはコンボ受付可能状態であれば、次のセクションにアニメーション通知に設定されたセクションを次のセクションとしてMontageに設定しています。

そして、コンボ入力の受付フラグを折ります。

 

以上のものを実装した結果、下記の動画のような動作をします。

 

 

 

明日は@dgtanakaさんの記事になります。