第15回ぷちコン「たぬ吉の大冒険」振り返りその2<GameplayAttribute、GameplayEffect編>
注》この記事はこの記事の続きです。
この記事では第15回ぷちコンの振り返りとして、GameplaySystemのうちGameplayAttributeとGameplayEffectを解説します。
お品書き
・GameplayModMagnitudeCalculationについて
・GameplayEffectExecutionCalculationについて
・前回の手直し
前回作成したプロジェクトなのですが、今回解説する内容と合わせるために若干手を入れようかと思います。まず前回作成した「GPA_Test」の名前を「GPA_Attack」に変更します。
次に攻撃にCollisionを付けます。
BP_GASCharacterにBoxCollisionを追加します。
こんな感じで。そうしたらCollision設定をこうして…
次にBoxCollisionにComponentTagを付けます。名前は「Attack」としておきます。
次にAnimationNotifyを作成します。
名前は「NF_ActivateAttack」としておきます。
RecievedNotifyをOverrideして…
このようにノードを組みます。要は「Attack」のTagが付いているBoxCollisionにOverlappingしているBP_GASCharacterのダメージAbilityを発動させる、ということです。これを…
攻撃モーションのこの辺りに設置します。
これでBP_GASCharacterをThirdPersonExampleMapに新たに置くと…
このように攻撃すると相手がのけぞるようになります。後でまた弄りますが、インタラクションがあったほうが楽しいので一旦こうしておきます。今回はここからスタートです。
・GameplayAttributeについて
GameplayAttributeはGameplayAbilitySystem内で個々のActorの持つ様々なパラメーター(HPやMP、攻撃力、防御力など)をfloat値として統括管理するシステムです。ゲームにはこれらの数値の管理が殆どの場合必須です。
GameplayAttribute自身はFGameplayAttributeDataという変数型で提供されています。AttributeはBaseValueとCurrentValueという2つの数値を持っており、このどちらにもアクセスできるようになっています。
GameplayAttributeを扱うにはAttributeSetというC++クラスが必要です。
・AttributeSetについて
AttributeSetはGameplayAttributeを定義し、それを扱うための関数やマクロを提供するためのC++クラスで、これをASCに付与することでAttributeにアクセスすることができるようになります。また、AttributeSetは複数違うものを持つことも実は可能です。
AttributeSetにはいくつかの仮想関数が定義されています。これらは重要なので少し見ていきましょう。
>void PreAttributeChange(const FGameplayAttribute& Attribute, float NewValue)
>void PreAttributeBaseChange(const FGameplayAttribute& Attribute, float NewValue)
Attribute(BaseValue or CurrentValue)が書き換えられる直前に処理が走ります。Clamp処理を掛けたりする際に使用します。
>bool PreGameplayEffectExecute(FGameplayEffectModCallbackData& Data)
GameplayEffectによりAttributeに変更が入る直前に処理が走ります。GameplayEffectの処理自体を一定条件で拒否したりといった用途に使用します。
>void PostGameplayEffectExecute(const FGameplayEffectModCallbackData& Data)
GameplayEffectによりAttributeに変更が入った直後に処理が走ります。Actorへの通知は基本的にここで行うことが推奨されているので、おそらく一番使う機会が多いと思います。
・GameplayAttributeを導入してみる
なにはともあれAttributeSetのC++クラスを作成しましょう。
名前は「GASAttributeSet」としておきましょう。
エディタを開いてGASAttributeSet.hに書き込んでいきます。
まずはAbilitySystemComponent.hをincludeします
続いてアクセサマクロを設定します。
これはGameplayAttributeからfloat値の取り出しやfloat値による書き換えをする際に使用します。
次にそれぞれのGameplayAttributeを宣言します。宣言するのはHealth、MaxHealth、AttackBase、AttackMultiplier、DefenseMultiplier、そしてDamageです。Publicに記述することを忘れないようにしましょう。(忘れるとコンパイルエラーが出ます)
ここでちょっと疑問に思うかもしれません。「Damageってパラメーターなの?」と。
DamageはMetaAttributeと呼ばれるもので、一般的なパラメーターではなく、ダメージ計算時に使用するものです。非常に便利なのですが、あとでGameplayEffectExecutionCalculationのところで具体的な使い方を説明します。
ついでにPostGameplayEffectExecuteを宣言します。
今の所中身は空ですが、cppファイル側で一応定義はしておきます。
さて、お次はGASCharacterクラスを弄っていきます。エディタを開いて、まずはGASCharacter.hでGASAttributeSet.hをincludeします。
宣言に使用するUPROPERTYマクロは引数なしで構いません。アクセスはPublicで。
追加の仕方は通常のコンポーネントと全く変わりません。しかし、もちろんコンポーネントではないのでBP側には何も表示されません。
とりあえずC++側はこれで終わりです。コンパイルしましょう。
さて、終わったらBP_GASCharacterの中身を見ていきます。
ダメージモーションは確認したのでもうこのノードは要らないです。なので…
Attributeにアクセスするためにアクセサを介した独自ノードをActorに実装する方法もあるのですが、個人的には必要無いと思います(分かりやすいので説明や習熟するためとしてはアリだと思いますが)。
上のようにAttributeをGetしたい時にはGetFloatAttributeFromAbilitySystemCompoentかGetFloatAttributeBaseFromAbilitySystemComponent、Setしたい時にはGameplayEffectを使用すれば問題ないと思います。
じゃあそもそもアクセサ要らないじゃん、というとそういうわけでもなく、C++内の関数で結局必要になったりします。
それからAbilitySystemが更新されているので、EventGraphのAbilitySystemノードを繋ぎ直す必要が出てきます。これは前の記事内でAbilitySystemを直接参照できるようにした弊害なのですが、まあこのぐらいは許容範囲とします。
それはさておき、実行してみましょう。
まだ何も弄っていないので数字は0です。ただこのノードはアクセスに失敗しても0を返すため、数字だけでは成功しているかが不明です。しかしBooleanがtrueになっているため、たしかにアクセスできていることが分かります。
さて、GameplayAttributeによってキャラのパラメーターを用意することに成功しましたが、パラメーター自体を弄ることがまだできていません。それをするために必要なのがGameplayEffectです。
・GameplayEffectについて
さあGameplayEffectは長いぞ。
GameplayEffectはAttributeに対し影響(Effect)を与えるものです。しかしそれに留まらず、めちゃくちゃ色んな事ができるようになっています。基本的にこのクラス自体はC++での実装は要らず、BPのみで取り扱えます。とりあえずGameplayEffectのBPクラスを作成して、中身を覗いていきましょう。
まずは初期化に使用したいので、名前は「GPE_Initialize」とします。
中身を開くとこのようになります。
GameplayEffectクラスでは弄るのはDetailsのみなので、左側は一切必要ありません!
そして肝心の右側には…
うんざりするぐらい色々並んでいます。これらを全て解説すると記事がもう2、3個必要になってきりがないので、必要なところだけを解説します。(ここでだいたい書かれているので、気になる人は見てください)まずは一番上の「GameplayEffect」カテゴリです。
DurationPolicyはInstant、HasDuration、Infiniteの三種類があり、それぞれEffectをどのぐらいの期間適用するかを決定します。Instantは即時で一回だけ。HasDurationは一定期間ずっと。Infiniteは永久に適用します。またこれはAttributeのBaseValueとCurrentValueを同時に変更するのか?それともCurrentValueのみ変更するのかに関わってきます。前者がInstantであり、後者がHasDurationとInfiniteです。
またこの選択によって下のカテゴリである「Period」が変化します。
[Instant]
[HasDuration] or [Infinite]
PeriodはEffectを適用する周期です。1/60とか1/24とか設定することが多いですが、0のまま使用することもあります。
ExecutePeriodicEffectOnApplicationはオンにするとEffectが適用された瞬間にAttributeが書き換わり、その後Period秒毎にAttributeが再び書き換わります。オフにするとEffectが適用された瞬間には書き換わらず、その後Period秒後に初めてAttributeが書き換わります。
PeriodicInhibitionPolicyは何らかの理由でEffectが中断された場合、NeverResetの場合は中断された場所から何事もなかったかのように再び適用が始まります。ResetPeriodの場合、Periodが一旦リセットされ、再開すると最初にEffectが付与された時と同じように振る舞います。ExecuteAndResetPeriodの場合、基本的にはResetPeriodと同じですが、中断された際に一度だけEffectが適用されます。
それからHasDurationを選択した場合は「GameplayEffect」カテゴリにDurationMagnitudeという項目ができます。
これはEffectの適用期間を決定するものです。
さてお次の項目はModifiersです。配列になっているのでちょっと追加して覗いてみると…
上から解説していきます。AttributeはEffectによって変更するTarget(GameplayEffectのSourceとTargetについては後で説明します)のAttributeを選択します。
現在はこんな風に選択できます。
ModifierOpはAttributeに対する計算方法です。
これは説明不要ですね。マイナスの値をAddに入れると数値の減少を表現することができます。
MagnitudeCalculationTypeは変更する元となる数値を決定します。
ScalableFloatは直接入力で数値を決定します。FCurveFloat型です。
AttributeBasedはSource又はTargetのAttributeを参照して数値を決定します(ある程度は変更もできます)。
CustomCalculationClassはGameplayModMagnitudeCalculationクラスを使用して数値を決めます。MagnitudeCalculationTypeの中では最も自由度が高くなりまが、C++での実装が必須となります。
最後にSetByCallerです。これは見てみた方が早いので(そして初期化に向いているので)、実装しながら見ていきましょう。
…と言いたいところですが、まだ一つ説明することがあります。GameplayEffectの適用方法についてです。
GameplayEffectは基本的に以下のようなノード構成で適用します。
[AbilitySystemComponentから適用する場合]
見ての通りAbilitySystemComponentからSpecHandleを作成して、それをノードに送って適用する、という流れです。そしてGameplayEffectにはASCに対してSource(適用する側)とTarget(適用される側)という区別があります。上の2つの例の場合、どちらも上側のASCがTarget側、下側がSource側になります。(この場合参照しているのは全く同じASCではありますが…)
[GameplayAbilityから適用する場合]
GameplayAbilityから適用する場合、Sourceは強制的にAbilityを所持している側のASCになります。そして上の例はとても単純にTarget側もAbility所持者のASCが担当している、ということなのですが…下側はTargetingというシステムが絡んでいます。これは名前こそ同じですがGameplayEffect内のTargetの概念とはまた違うものでして…一応オフラインでも用いることはできるものの、上手く活用するためにはネットワークが絡んでくるみたいなので、僕もまだ良くわかってません。なので触れないでおきます。
・GameplayEffectを導入してみる
さて、やりたいことは各AttributeのInitializeです。それにはどのMagnitudeCalculationTypeが最適でしょうか?
ScalableFloatとAttributeBasedは便利な場面ももちろんありますが、基本的にBP側のfloat値を参照できません。例えばパラメーターをInitializeする場合、キャラクターのレベルに応じた数値をDataTableから引っ張ってくることやBPエディタに公開したVariableを参照する方法が考えられます。その場合BPのfloat値が使えないと数値を持ってこれません。CustomCalculationClassは最も自由度が高い方法なので、もちろんBPの数字を持ってくることができますが、C++での実装が必須になるのでお手軽ではありません。というわけで、この場合Initializeに最適なのはSetByCallerとなると思います。
SetByCallerにはAssignTagSetByCallerMagnitudeというノードを使用します。AssignSetByCallerMagnitudeというノードもありますが、こちらはどうやらC++無しでは今の所機能しないようです。
このように繋げると…
Tagとfloatが紐付けられた情報であるSetByCallerがSpecにAssignされます。EventBeginPlayノードにこれをどんどん繋げて…(初期値はVariable化しています)
GPE_Initializeの方もずらずらずらっと全部SetByCallerで…
Xキーを押したら可視化できるようにしていきましょう。
これで実行中にXキーを押すと…
このようにGameplayEffectを介してAttributeを変更することができたことが分かります。
・GameplayModMagnitudeCalculationについて
最も自由度が高いMagnitudeCalculationTypeであるCustomCalculationClassにおいて計算に使用するのがGameplayModMagnitudeCalculation(以下GMMC)クラスです。これはBPでは中身を操作できずC++実装オンリーのクラスで、その本体はCalculateBaseMagnitude_Implementation(const FGameplayEffectSpec& Spec) constというfloat値を返す関数です。他のMagnitudeCalculationTypeでできる全てのことが可能で、かつそれらの値を使用した複雑な計算式や条件式を作成することができます。
ではGMMCを使用した攻撃ダメージ処理を作っていきましょう。GMMCの派生クラスを作成します。
名前は「GMMC_Attack」としました。
ヘッダを開いて、まずは宣言から…
コンストラクタとAttribute情報を格納するためのFGameplayEffectAttributeCaptureDefinitionという型の変数を3つ用意します。それから本体となるCalculationBaseMagnitude_Implementationですね。
次はcppファイルを開きます。まずAttributeの参照に必要なAttributeSetクラスをincludeします。
次にコンストラクタでSource側のAttackBase及びAttackMultiplier、Target側のDefenseMultiplierをCaptureします。
SnapshotをtrueにするとEffectSpecが作成された瞬間、falseにするとそのSpecがGameplayEffectによって適用された瞬間にAttribute情報がCaptureされます。活用例としてProjectileのActorなどが分かりやすいですが、射出時に射出側のActorでAttributeをSnapshotしてSpecを作成し、ProjectileはそのSpecの情報のみを保存して運び、被弾側のActorに至って初めてそのSpec情報を参照してGameplayEffectとして適用すれば、ProjectileのSpecが持っているのは射出時点のAttribute情報となります(逆にSnapshotしていなければ被弾時点でのAttribute情報が参照されます)。はっきり言って、このようなMeleeな攻撃では効果を実感しづらい代物ですが、一応Source側はtrue、Target側はfalseにしておきます。
次はCalculationBaseMagnitude_Implementationを弄っていきます。
まずはSourceとTargetのTagを集め、EvaluationParametersに投入します。TagによってはCaptureした数値が(PreAttributeChangeなどにより)変化する可能性があるからです。そして用意したLocal変数にGetCaptureAttributeMagnitudeを介して値を代入していき、最終的に計算を行ってその値を返り値にします。(除算がある場合はくれぐれもゼロ除算に注意しましょう)
ここまで書いたらコンパイルします。
次はBP側です。新しいGameplayEffectの派生BPクラスを作成します。名前は「GPE_Attack」とします。
Detailsはこのようにします。
Coefficientが-1.0になっていることに注意しましょう。
次にNF_ActivateAttackの中身を書き換え、TryActivate~の直前にGPE_Attackを適用します。
と、これで一応プログラム上は問題ありませんが、可視化されていないため本当にEffectが動いているのかどうかが分かりません。なので様々な改良も含め、また少しプロジェクトを弄ります。
いきなりですが、まずはここからTryActivateAbilitiesByTagを削除します。
次にGASCharacter.hを開き、Publicに以下のようにOnDamaged(float Damage, float Health, AActor* Attacker)とOnDied(float Damage, AActor* Attacker)という関数を宣言します。
これは宣言のみで、cppファイル内に定義を書く必要はありません。
次に弄るのはGASAttributeSet.cppです。以前殆ど宣言しただけで放置したPostGameplayEffectExecuteの中身を書いていきます。
まずは先程のOnDamagedやOnDiedを扱うためにAGASCharacterクラスへのアクセスが必要なので"GASCharacter.h"をincludeします。様々なGameplayEffect関連の情報を扱う必要もあるため、"GameplayEffect.h"と"GameplayEffectExtension.h"もincludeしておきましょう。そうしたらEffect処理後の処理を書いていきます。
ここでやっていることはまずはTargetのActorとSourceのActorを取得して、それからEffectによってHealthの数値に変更があった場合に、Healthの変更がマイナス方向で、かつ変更後の数値が0以上ならTargetのOnDamagedを呼び出し、0以下ならOnDiedを呼び出す処理です。その後にDamage(前にAttributeSet内に定義したMetaAttribute)を0にし、最後にHealthをClamp処理して終了します。
これからBP側を弄るのですが、下準備としてダメージを受け続けて死んだ時用のAbilityを作成します。
Animationの用意が面倒なのでPhysicsAnimationにしました。
TagはAbility.Deathで呼び出せるようにしました。
そして今度はダメージリアクションのAbilityをキャラクターに設定します。まずBP_GASCharacterを開きます。BlueprintImplementableEventで宣言したので、BP_GASCharacterのEventGraphでは"OnDamaged"と"OnDied"がEventとして呼べるようになっています。
それぞれ以下のように繋ぎます。
DamageとDeath、それぞれリアクションとしてのAbilityの呼び出しと、パラメーター情報の表示をさせます。
最後にAbilityListにAbilityを登録するのを忘れないようにしましょう(よく忘れる)。
次に新たなGameplayEffectを作成します。名前はGPE_ConvertDamageToHealthとします。
中身はDamageの数値をマイナス方向のHealthに変換するだけです。AttributeSourceをTargetにするのを忘れないようにしましょう。
GPE_Attackも少し直します。
書き換えるAttributeをDamageに、Coefficientを1.0に変更しました。
そして…
GameplayEffectsカテゴリのConditionalGameplayEffectsにGPE_ConvertDamageToHealthを追加します。ConditionalGameplayEffectsはGameplayEffectが成功した場合にここに登録されたEffectが続いて発動します。これはEffectで変更したAttributeを、変更した後に再びEffectで使用したい場合などに使用します。基本的にAttributeのCaptureは一度しか行われず、一つのEffect内では変更後のAttributeを扱うことができません。しかしEffectを何度も呼び出すのもノードが増えてスマートではない(更に言うと何度も書くのがそもそも面倒)のでこのような項目が用意されています。
基本構造としてはBP_GASCharacterがGPA_Attackを発動した際に、AttackCollision内にいる他のBP_GASCharacterにGPE_Attackを適用、続いてGPE_ConvertDamageToHealthが適用され、それがHealthに変換されます。Healthが変更されたのでPostGameplayEffectExecuteからTargetActorのEventの発動やHealthのClampが行われ、最後にDamageの値が0になって次の攻撃に備える、となります。
なぜDamageというMetaAttributeを用意したかというと、こうしてHealthとDamageを一旦切り離すことでBuff、Debuffやアイテムなどによる数値の変更がやりやすくなる、という効果があります。例えば防具などを装備しているとある程度のダメージを肩代わりしたり、ダメージを割合で減らしたりする場合、MetaAttributeが無く直接Healthを減算する形とすると、後のEffectなどでもともとのダメージ値が参照できなくなってしまい、効果を手軽に追加・削除できなくなってしまいます。小さなプロジェクトなら全く問題が無い場合もあるのでケースバイケースではあるのですが、この使い方自体は覚えておいたほうがいいと思います(GameplayAbilitySystem外でも有効です)。
またMetaAttributeを複数用意することも有用です。このプロジェクトでは現在毒や火傷などといったDOT系統のダメージを実装すると少々問題が起こります(Healthの変更が行われる度にAnimationが再生される仕様なので)。これは新たに「DOTDamage」などといったMetaAttributeを作成し、PostGameplayEffectExecuteを書き換えると解決するのですが、ここでは今は触れないでおきます。
さて、ここまでGMMCの実装について書いてきましたが、実はGameplayEffectにはもっと自由度の高い計算方式があります。それがGameplayEffectExecutionCalculationです。
・GameplayEffectExecutionCalculationについて
GameplayEffectExecutionCalculation(以下GEEC)はGameplayEffect内で最も自由度の高い計算方式です。GMMCでできることに加えて、AttributeからCaptureする数値自身をBPから操作することができます。
今回はこれを使用してHP吸収攻撃を作成していきたいと思います。
まずはGEECクラスを作成していきましょう。ちなみにこちらもBPでの実装はできず、C++オンリーのクラスとなっています。
名前はGEEC_DrainAttackとします。
GEEC_DrainAttack.hに宣言を書き込みます。宣言するのはコンストラクタとExecute_Implementationという引数がやたらに長い仮想関数です。
今度はGEEC_DrainAttack.cppに移動し、まずは"GASAttributeSet.h"と"AbilitySystemComponent.h"をincludeします。
次にcpp内に"DrainStats"というStructureを作成します。GEECにはCaptureについてのマクロが提供されているのでカンタンです。
DEFINE_ATTRIBUTE_CAPTUREDEFの引数は左から順にクラス名、Attribute名、Target or Source、Snapshotとなっています。
次にコンストラクタです。
Structureとマクロを使用しているだけで、やっていることはGMMCと殆ど変わりません。
続いてExecute_Implementationです。一気に完成形までやっていきます。
GMMCと引数や関数の名前が変わっていますが、ここでもやっている事自体は前回とあまり変わりません。変わったことは、RecoverMultiplierというSetByCallerを参照し、Damageに掛けたものでSourceを回復させている点です(回復方法はあまりスマートじゃありませんが…)。このようにGMMCやGEECではSetByCallerから数値を参照することもできます。
さて、これをコンパイルして…
終わったら今度はBP側です。GPE_DrainAttackというGameplayEffectを作成します。
中身はこうします。
ここでは使用しませんが、CalculationModifireを追加するとCaptureする値にModifierの項目と同じような変更を施すことができるようになります。これはGEECの強みの一つで、実行結果を見ながら少しづつ係数を上げたりといった数値の微調整をする場合などに役に立ちます。
Abilityの作成の仕方はもうやっているので省略しまして、NotifyはNF_ActivateAttackをDuplicateしたところから…
こうなってる部分を…
こんな風に変更します。
それからちゃんと機能しているか可視化するために自身のHealthを減らす仕組みも必要ですね。"GPE_ReduceHealth"というGameplayEffectを用意しまして…
一気に80減らします。
最終的に…
ZキーとXキーで通常攻撃と吸収攻撃…
CキーでHealthを減らして…
Qキーを押すとステータスを表示するということにしました。
実行してみましょう。
実行時にQキーを押すとこうなります。Cキーを押しまして…
再びQキーを押すとこんな感じになります。Healthが80減って20になっていますね。
敵に近付いてXキーを押して攻撃してみましょう。
そうしてからまたQキーを押すとこうなります。
元のHealthは20、そして攻撃力は20なのでその0.4倍である8回復して28になっています。これにてHP吸収攻撃を実装することに成功しました。
いやーやっと終わりました。長くなるんじゃないかとは思ってたんですが、めちゃくちゃ長くなってしまいましたね。GameplayEffectはこれでも一部分ではあるんですが…書きながら調べてるとどんどん書くべきことが増えてしまってどうにもならない…
さて、お次はGameplayEventとGameplayTask編です。正直ネットワークについて分からないと意味がない部分も多い感じですが頑張ります。それではまた。
ご指摘、ご質問、ご意見などあればコメントにお願いします。
続きの記事はこちら