ぱぴブログ

さんたろのブログ

ゲーム開発が好きな人のひとりごと

Unity 戦闘システムの完成(ターン制・行動選択式)

はじめに

みなさんこんにちは。コロナが怖いさんたろです。
来年度はインターン(就活)が本格的に始まりますが、オリンピックもありますし不安です。

さて、今回は前回記事で書いていた、ターン制・行動選択式の戦闘システムの基礎が完成したお話です。

前回の記事はこちら↓

papyrustaro.hatenablog.jp

GitHubにブランチだけ分けてあげています↓

github.com

実際に戦闘システムを使う様子

実際Unity上でどのように動くのか動画にしました。

大まかに機能を分けると
・キャラクターの作成
・作成したキャラクター同士の戦闘
の2つです。

www.youtube.com

実装面のお話

Passive効果の条件判定

一時的なバフではなく、常時発動される効果(PassiveEffectと名付けてます)の効果適用で問題が発生しました。
このPassiveEffectには発動条件をつけることができます。
「Hpが最大Hpの〇%以上or以下」といったものです。

私は最初PassiveEffectのリストを先頭から順に適用させる実装をしていましたが、これだと問題が生じます。
例えば、

・「最大Hpの80%以下で水属性になる」
・「水属性のときに、攻撃力が1.5倍になる」

の2つの効果を適用させようとした場合、もともと水属性ではないキャラクターはPassiveEffectの効果の適用順序によって攻撃力が変わってしまうかもしれません。

そのため、PassiveEffectの適用前の状態で、常に条件判断をしなければいけません。

以下、実際のPassiveEffect適用部分です

    /// <summary>
    /// Set all passiveEffects by skills and items.
    /// </summary>
    public void SetPassiveEffect()
    {
        foreach(BattleCharacter bc in this.allyList)
        {
            bc.RememberCondition(); //PassiveEffect反映の条件保持
        }
        //itemのpassiveEffect反映
        foreach(BattlePassiveEffect effect in this.havePassiveItems)
        {
            this.passiveEffectFuncs.EffectFunc(effect, this.allyList); //itemは見方全体にのみ対象
        }

        //skillのpassiveEffect反映
        foreach(BattleCharacter bc in this.allyList)
        {
            for(int i = 0; i < bc.PC.HaveBattlePassiveSkillID.Length; i++)
            {
                if (bc.PC.UseAbleBattlePassiveSkillLV[i] > bc.PC.LV) break; //解放されていないスキルは飛ばす
                BattlePassiveSkill passiveSkill = this.passiveEffectFuncs.GetBattlePassiveSkill(bc.PC.HaveBattlePassiveSkillID[i]);
                if (passiveSkill.EffectIsOnlyFirst() && this.currentSituation != E_BattleSituation.SetParameterBeforeStartBattle) break; //戦闘開始時のみ適用スキル
                switch (passiveSkill.TargetType)
                {
                    case E_TargetType.AllAlly:
                        this.passiveEffectFuncs.EffectFunc(passiveSkill, this.allyList);
                        break;
                    case E_TargetType.Self:
                        this.passiveEffectFuncs.EffectFunc(passiveSkill, new List<BattleCharacter>() { bc });
                        break;
                    case E_TargetType _:
                        Debug.Log("error");
                        break;
                }
            }
        }
        foreach(BattleCharacter bc in this.allyList)
        {
            bc.InitCondition(); //一応条件保持パラメータを初期化
        }
    }

ちゃんとドキュメントコメント書こう

ある程度コード量が多くなると、当然細部を忘れるものです。

さて、下の2行を見てどう思うでしょう。
補足しておくと、ToDamageRateは「火」「水」「木」属性への敵に与えるダメージ倍率です。
キャラクターの属性(Element)は「火・水」など複数の属性を持てます。

private Dictionary<E_Element, double> ToDamageRate => GetRate(PassiveToDamageRate, toDamageRate);

public double NormalAttackPower => Atk * ToNormalAttackRate * GetToDamageRate(Element);

私はこう思いました。
「なぜわざわざ『GetToDamageRate(element)』などと書いたのだろう。『ToDamageRate[Element]』でいいではないか。」

そしてGetToDamageRate関数の定義も見ずに書き換える。

public double NormalAttackPower => Atk * ToNormalAttackRate * ToDamageRate[Element];

しかし、この場合、もしキャラクターの属性が「火・水」など複属性であった場合、KeyNotFoundExceptionとなります。

ここで、GetToDamageRate関数を見てみると、

    public double GetToDamageRate(E_Element attackElement)
    {
        return ElementClass.GetRate(ToDamageRate, attackElement);
    }

    ///ElementClass.cs内にて
    public static double GetRate(Dictionary<E_Element, double> dic, E_Element searchElement)
    {
        double rate = 1;
        if (IsFire(searchElement)) rate *= dic[E_Element.Fire];
        if (IsAqua(searchElement)) rate *= dic[E_Element.Aqua];
        if (IsTree(searchElement)) rate *= dic[E_Element.Tree];
        return rate;
    }

と、いうわけで一見無意味な関数はKeyNotFound回避のための大事な処理だったのです。
このようなことをなくすためにコメントを残すことが大事です。

また、ToDamageRateのアクセス修飾子はprivateになっています。 書いた当時の私は、クラス外から迂闊に利用してKeyNotFoundにならないように、配慮してくれていました。

Visual Studioでは///と打つだけで、自動で関数の定義を記述できます。
また、関数にカーソルを乗せることで、関数の情報表示されます。
関数を呼ぶ際も、引数・返り値の情報が表示されるので、大変便利です。
少しでも不安なら、バリバリ書いていきましょう。

f:id:papyrustaro:20200221225321p:plain
ToDamageRateにカーソルを乗せたとき

できるだけ関数の呼び出され側で処理

BattleCharacterクラスは、キャラクターの基本情報を保持するCharacterクラスを持ちます。
ゲーム起動時にBattleCharacterクラス内から、同オブジェクト内に存在するCharacterクラスを探して、アタッチしています。

public class BattleCharacter : MonoBehaviour
{
    private Character charaClass;
    public Character CharaClass{ get { return this.charaClass; } }

    private void Start(){
        SetCharacter();
    }

    private void SetCharacter()
    {
        if (this.gameObject.CompareTag("Player"))
        {
            this.charaClass = GetComponent<PlayerCharacter>().CharaClass;
        }
        else
        {
            this.charaClass = GetComponent<EnemyCharacter>().CharaClass;
        }
    }
}

さて、戦闘開始時に、他のクラスからBattleCharacter内のCharaClassを参照したいとします。
しかし、まだCharaClassがセットされているかわかりません。
このような場合、nullチェックを他のクラスからしようとすると、

public class Sample : MonoBehaviour
{
    [SerializeField] private BattleCharacter bc;
    private void Start(){
        if(bc.CharaClass == null) bc.SetCharacter();
        Debug.Log(bc.CharaClass.CharaName);
    }
}

しかし、呼び出し側から毎回nullチェックは馬鹿馬鹿しいです。
呼び出し側で意識することをできるだけ減らすように心掛けると、後々ミスや無駄な思考を減らせます。

上の場合、CharaClassを参照する際にnullだった場合、BattleCharacterクラス側で処理しましょう。

public class BattleCharacter : MonoBehaviour
{
    public Character CharaClass
    {
        get
        {
            if (charaClass == null) SetCharacter();
            return charaClass;
        }
    }
}

バフのターン経過をどうする

戦闘システムの仕様として、キャラクターにかかっているバフなどの効果は、そのキャラクターの行動が終わった後に1ターン経過する、というものにしています。

しかしこの仕組み通り素直に実装すると、
「味方全員が1ターンの間攻撃2倍」
というスキルを勇者Aが発動したときどうなるでしょう。

発動者である勇者Aは効果適用後、行動が終わった段階ですぐにバフが消えてしまいます。
他の味方は攻撃2倍で一度行動できますが。

この「効果発動者の発動した効果が、行動終了後すぐに1ターン経過してしまう問題」をどう解決しましょう。
私は2つの案を思いつきました。

①発動した発動者への効果のみ、行動終了後ターン経過させない
②発動した発動者への効果のみ、実際の効果ターンに1加える

①はターン経過処理への修正、②は効果適用処理への修正になります。

私はまず①の実装を試みました。実際にしてほしい処理です。②の場合だとUI表示の際などに、面倒になりそうです。

発動者への効果のみ、そのターン、ターン経過させない

効果適用ターンが決まっているものをAcitiveEffectと呼んでいます。
ActiveEffectは基本、「残りターン数」「効果に必要なパラメータ(倍率など)」のパラメータを持っています(BuffEffectクラス)。
そしてそのActiveEffectをListやDictionaryとして、追加・削除をしています(dictionaryの場合、値増減)。

    //ActiveEffectの状態保持
    private List<BuffEffect> hpRate = new List<BuffEffect>();
    private List<BuffEffect> atkRate = new List<BuffEffect>();
    private List<BuffEffect> spdRate = new List<BuffEffect>();
    private List<BuffEffect> toDamageRate = new List<BuffEffect>();
    private List<BuffEffect> fromDamageRate = new List<BuffEffect>();
    private Dictionary<E_Element, int> noGetDamagedTurn = new Dictionary<E_Element, int>() { { E_Element.Fire, 0 },{ E_Element.Aqua, 0 },{E_Element.Tree, 0} };
    private BuffEffect elementChange;
    private List<BuffEffect> normalAttackNum = new List<BuffEffect>(); 
    private List<BuffEffect> toNormalAttackRate = new List<BuffEffect>(); 
    private List<BuffEffect> fromNormalAttackRate = new List<BuffEffect>();
    private Dictionary<E_Element, int> attractingEffectTurn = new Dictionary<E_Element, int>() { { E_Element.Fire, 0 }, { E_Element.Aqua, 0 }, { E_Element.Tree, 0 } };
    private List<BuffEffect> hpRegeneration = new List<BuffEffect>();
    private List<BuffEffect> spRegeneration = new List<BuffEffect>();

以下ターンの経過処理です

    public void ElapseTurn(List<BuffEffect> buffList)
    {
        foreach(BuffEffect bf in buffList)
        {
            bf.EffectTurn--;
        }
        buffList.RemoveAll(bf => bf.EffectTurn < 1);
    }

    public void ElapseTurn(Dictionary<E_Element, int> dic)
    {
        if (dic[E_Element.Fire] > 0) dic[E_Element.Fire]--;
        if (dic[E_Element.Aqua] > 0) dic[E_Element.Aqua]--;
        if (dic[E_Element.Tree] > 0) dic[E_Element.Tree]--;
    }

    public void ElapseAllTurn()
    {
        ElapseTurn(this.hpRate); ElapseTurn(this.atkRate); ElapseTurn(this.spdRate);
        ElapseTurn(this.toDamageRate); ElapseTurn(this.fromDamageRate); ElapseTurn(this.noGetDamagedTurn);
        ElapseTurn(this.elementChange); ElapseTurn(this.normalAttackNum); ElapseTurn(this.toNormalAttackRate);
        ElapseTurn(this.fromNormalAttackRate); ElapseTurn(this.attractingEffectTurn);
        NormalAttackToAllTurn = ElapseTurn(this.NormalAttackToAllTurn);
    }

さて、ターン経過をさせないためには何がわかればいいでしょうか。
それはどれがターン経過させない効果かです。
言い換えればどの変数のどの要素が今回発動した効果かですね。

「どの要素か」という部分はListなら最後に挿入した要素なので問題はありません。
では辞書型や値ひとつで記憶しているものは...?
こちらもListなどで管理すれば要素ひとつで分かりますが、わざわざ実装を変えたくはないです。

「どの変数か」。これが大変やっかいです。
もともとActiveEffectにはEffectTypeという変数で「Hpバフ」「攻撃集中」など効果の種類が定められています。

しかし、これだけでは、特殊な効果を実装することができないため「その他」という枠を設けています。

では「その他」の効果に対応する「変数」はどれでしょう...わかりませんね。

と、ここまで考え、①の実装を諦めました。

発動者への効果のみ1ターン多く適用

こちらは大変簡単です。
まず、効果の発動には3つの情報が渡されます。
「発動者」「効果対象(複数可)」「Effectクラス(倍率や効果ターンなど)」

そのあと、「発動者==効果対象」となったときのみEffectクラスの効果ターン+1すればいいだけです。
簡単ですね。

しかし、理想の動きではないので避けたい実装方法です。

バフの効果適用を、ターン経過処理のあとでおこなう

これは友人からのアドバイス。①より実装が楽で、②より理想的な動きです。
ここで問題となるのが、ActiveEffectがバフなのかどうか。
攻撃スキルなのにターン経過処理のあとでおこなっては、いけないですからね。

「その他」の処理がどうなるかわからないため、BattleCharacter内で、
バフ効果の保持→ターン経過→バフ効果適用
を処理すれば、できるでしょう。

今後

これからは、ダンジョンシステムの基礎をまず作っていきたいです。
幸い、知人からたくさんアイデアをもらい、戦闘システムを作成している間にどんなゲームにするかは決まりました。

また、並行して共同開発1本と、数学物理C++の勉強もできればいいですね。
うーんあと1年休みほしい笑