Strategyパターンを使って敵アルゴリズムを切り替えよう

はじめに

こんにちは!3回生コーディング課のポテリーです!クリスマス(イブ)^3となりましたが、いかがお過ごしでしょうか。大学生活も3年目になり、夢の中の自分はもっと派手な生活を送っているはずでしたが、現実とはいつだって非情なものです。皆さんは今年という一年を有意義に過ごせましたでしょうか。私は今年からUnityを始めました。コーディング課なのに今年から始めました。Unityの機能理解、C#文法、オブジェクト指向、デザインパターン…思い返せば沢山のことを学びましたね。私は逆に遊ばな過ぎたなと思って後悔してます。でも仕方ないんです。忙しかったんですから!!3回生になりサークルでは最上回生、役職が付き責任重大!夏冬インターンに向けた制作、学習、ES作成、(あとバイト)…。大学の授業やテストもある中でよくやったと思います(犠牲はつきもの)。よく「大学生は人生の夏休み」と言いますね。理系のあなた達にそのようなものはないので諦めてください。あとJR新大阪駅(在来線)東改札口にあるビフテキ重のお店が美味しかったので是非行ってみてください。

導入が長くなりましたが、今回の記事のテーマは「Strategyパターン」です。頑張って勉強してきたので今回はその内容及び具体実装を行っていきたいと思います^^

読者の対象

「デザインパターン」や、「オブジェクト指向」の内容を全く知らない人はそこから学習するといいと思います。DIやMVPパターンについての知識がある程度あると尚読みやすいかと思います。なお私も完璧にこれらを理解しているわけではないのでご了承ください。なにせ奥が深いし、人によって見解が違うので…

Strategyパターンってなんだろう

StrategyパターンはGoF 23パターンの内の1つです。GoFは3つのカテゴリに分類されていて、Strategyパターンはこのうちの「振る舞いに関するパターン」に分類されます。

使わない時に起こる問題

皆さんは場合によって処理を分ける場合、どのようなコードを書くでしょうか。おそらくほとんどの人がif文での条件分岐を想像すると思います。

if (条件)
{
  // 処理
}
else if (条件)
{
  // 処理
}
else
{
  // 処理
}

では場合に分けた処理の一つ一つが膨大になった場合はどうでしょうか。コードが見にくくなりますよね。ここで他クラスに処理を移してメソッドを使う形式にしてみましょう。

if (条件)
{
  _anyClass.Process1();
}
else if (条件)
{
  _anyClass.Process2();
}
else
{
  _anyClass.Process3();
}

処理がまとまって見やすくなりますね。では条件分岐が多くなった時はどうでしょうか。またコードが見にくくなりますよね。しかも条件分岐の管理が大変。他クラスにこの条件分岐ごと移してもそこでも結局見にくさ、管理の難しさは起こってしまいます。また以下のような事象も起こります。

if (条件1)
{
  _anyClass.Process1();
}
else if (条件2)
{
  _anyClass.Process2();
}
else  // 条件3
{
  _anyClass.Process3();
}

ThisClassMethod();  // 何らかの必要な前処理

if (条件1)
{
  _anyClass.Process4();
}
else if (条件2)
{
  _anyClass.Process5();
}
else  // 条件3
{
  _anyClass.Process6();
}

処理が追いにくくなりますよね。この組み合わせが何個もあったら最悪です。動的な処理の切り替えなんて考えたら頭がパンクしてしまいます。ここで出てくるのがStrategyパターンです。

目的と例

目的を端的に言えばズバリ…「アルゴリズムをまるっと入れ替え!他のアルゴリズムに変えれるようにしちゃおう!」です。具体的には、アルゴリズムごとにクラスを作成することで、オブジェクトとして分離させます。

どんな場合に使われるのか、具体例をいくつか挙げてみたいと思います。

  • 敵ごとに索敵アルゴリズムが違うとき(一番攻撃力が高いやつを狙ったり、一番HPが少ないやつを狙ったり、一番近いやつを狙ったり)
  • 持つ武器によってダメージ計算アルゴリズムが違うとき(斧は確率でクリティカルダメージを追加、剣はコンボでダメージアップ、等)
  • 行動アルゴリズムを変えたいとき(オートモード選択では自動的にゲームが進行、非選択ではプレイヤー入力ベースでゲームが進行)
  • 検索アルゴリズムを変えたいとき(データ量に応じて線形探索や二分探索を使い分ける等)

以上の用途に共通しているのは、「ゴールが一つで達成の仕方が複数」という点と、「どれもアルゴリズムを対象としている」という点です。この二つを確認すれば、このパターンを使用するべきか否かが見えてきます。逆に、使用するべきではない場合について挙げてみましょう。

  • ダメージ計算方法は「Attack – Defense」で固定一種類だけであれば、アルゴリズムをクラスに分けるのは過剰設計になる可能性がある)
  • 難易度Easyでは敵のAttackは10、制限時間は2分、難易度Hardでは敵のAttackは100、制限時間は30秒(処理の違いが「値」だけであるときはScriptableObjectでのConfig設定や、データクラスの作成の方がシンプル)

目的は、if文やフラグでの条件分岐をなくすことでアルゴリズムの追加、及び削除動的な処理の切り替えを行いやすくすることです。デザインパターンの使用が目的になることだけには注意が必要!

使い方

Strategyパターンは以下の三つの役割で構成されます。

  1. Strategy(アルゴリズムクラスを共通に管理するために必要なインターフェース)
  2. ConcreteStrategy(Strategyを実装した具体的なアルゴリズムクラス)
  3. Context(利用側)

Context側のクラスはStrategyインターフェースを持ちますが、その中身がどのようなアルゴリズムクラス(ConcreteStrategy)なのかは把握していません。利用側は各ConcreteStrategyの差異を気にすることなく具体処理を参照できます。下記はそれぞれのクラスのざっくりとした例です。AnyContextクラスがコンストラクタにてStrategyを獲得し、StartAnyStrategy()内で使用しています。

// アルゴリズムを実行する利用者側クラス
public void AnyContext()
{
    private IAnyStrategy _strategy;  // 何かのStrategyを保持

  // コンストラクタにて何かのStrategyを受け取る
    public AnyContext(IAnyStrategy strategy)
    {
        _strategy = strategy;
    }

    // どこからかこのメソッドを呼び出す
    public void StartAnyStrategy()
    {
        _strategy.Foo();  // 受け取った何かのアルゴリズムを開始
    }
}

// ConcreteStrategyを仲介するStrategyインターフェース
public interface IAnyStrategy
{
    void Foo();
}

// 以下各Strategyを定義したクラス
public void ConcreteStrategy1 : IAnyStrategy
{
    public void Foo()
    {
        // 具体処理
    }
}

public void ConcreteStrategy2 : IAnyStrategy
{
    public void Foo()
    {
        // 具体処理
    }
}

public void ConcreteStrategy3 : IAnyStrategy
{
    public void Foo()
    {
        // 具体処理
    }
}

メリット

Strategyパターンのいいところは沢山言ったように思えるのですが今一度まとめます。

  • 利用者側が、同一の目的を達成するためのアルゴリズム群をその差異を気にすることなく単一のものとして使用することが出来る
  • 新しい目的達成方法を追加したいとき、既存コードをほとんど意識しなくて良い
  • 無意識にロジックを分離できる
  • アルゴリズムを定義する側(ConcreateStrategy)と、使う側(Context)で責務が明確になる
  • アルゴリズムごとにテストを行いやすくなる

いいところいっぱいいっぱいで幸せですね。特にこのアルゴリズム部分を別クラスにして責任分離しているところが気に入っています。

デメリット

  • クラス数が多くなり、煩雑になりやすい
  • (各Strategyに渡せる依存が固定になる)

使っている中で気になったのはContext内で各Strategyにメソッドを通じて参照を渡す場合、挿入する参照群を固定にしなければならないことです。しかしこれはデメリットというよりかは、それを問題視する時点で設計が歪んでいることを指していると思っています(勝手に)。具体クラスやContextクラスなどの参照固定は無くすべきです。作成するアルゴリズムごとに必要な具体クラスやContextクラスの参照が変わるとContextがアルゴリズムを意識する必要が出てしまい破綻しますよね。詳しい内容については後で触れたいと思います。

具体実装

今回は簡易的な索敵アルゴリズムをStrategyパターンを使用して実装することにします。登場人物はSlime2体、Turtle2体。この4体の敵を生成して、そのうち3体は動けないように固定、残った1体がどの敵に向かうかのアルゴリズムを可変にしていきます。


私はDIが好きなのでそれっぽく今回の実装のEntryPointを作成しました。物好きは見てください。

using NUnit.Framework;
using System.Collections.Generic;
using Unity.VisualScripting;
using UnityEngine;
using System;

/// <summary>
/// 敵ごとにモデル、ビュー、プレゼンターをまとめた構造体
/// </summary>
public struct EnemySet
{
    public EnemyModel Model;
    public EnemyView View;
    public EnemyPresenter Presenter;

    public EnemySet(
        EnemyModel model,
        EnemyView view,
        EnemyPresenter presenter)
    {
        Model = model;
        View = view;
        Presenter = presenter;
    }
}

/// <summary>
/// DIコンテナ的な役割を果たすクラス
/// </summary>
public class EnemyEntryPoint : MonoBehaviour
{
    // 敵全体のビューリスト(インスペクターで設定)
    [SerializeField] private List<EnemyView> _enemyViews;

    // 敵ごとのMVPのセットリスト
    private List<EnemySet> _enemySets;

    // 今回は敵を4体生成する
    private void Start()
    {
        _enemySets = new List<EnemySet>();

        EnemyModel[] models =
        {
            new SlimeModel(),  // 動ける敵 

            new TurtleModel(),  // 動けない敵(検証都合)
            new SlimeModel(),  // 動けない敵(検証都合)
            new TurtleModel()  // 動けない敵(検証都合)
        };

        // 一体ずつMVPと移動アルゴリズムを生成してセットにまとめる
        for (int i = 0; i < models.Length; i++)
        {
            var model = models[i];
            var view = _enemyViews[i];

            // viewにて設定してある種類のStrategyを生成
            var strategy = CreateStrategy(view.StrategyType);

            // プレゼンターを生成
            var presenter = new EnemyPresenter(
                model,
                view,
                _enemySets,
                strategy
            );

            // MVPをセットにまとめてリストに追加
            _enemySets.Add(new EnemySet(model, view, presenter));
        }
    }

    // 全てのEnemyのUpdateを回す
    private void Update()
    {
        foreach (var enemySet in _enemySets)
        {
            enemySet.Presenter.Tick();
        }
    }

    /// <summary>
    /// Strategyを生成する
    /// </summary>
    /// <param name="type">Strategy名</param>
    private ITargetSelectStrategy CreateStrategy(TargetSelectStrategyType type)
    {
        return type switch
        {
            TargetSelectStrategyType.Nearest => new NearestTargetStrategy(),
            TargetSelectStrategyType.LowestHp => new LowestHpTargetStrategy(),
            _ => throw new ArgumentOutOfRangeException()
        };
    }

}

下記がストラテジの種類とストラテジインターフェースの定義です。今回の索敵アルゴリズムは、「近い敵を狙う」ものと、「HPが低い敵を狙う」ものの二つです。ストラテジインターフェースでは、自分(敵)を表すmodel、view、加えて敵全員のMVPを渡しています。

using System.Collections.Generic;
using UnityEngine;

public enum TargetSelectStrategyType
{
    Nearest,
    LowestHp,
}

public interface ITargetSelectStrategy
{
    Transform SelectTarget(
        EnemyModel model,
        EnemyView view,
        List<EnemySet> candidates);
}

Enemy関係は今回MVPで書いてます。EnemyViewだけMonoBehaviourで、敵オブジェクトにアタッチしています。他はpureC#です。

using UnityEngine;
using System.Collections.Generic;

public class EnemyView : MonoBehaviour
{
    [SerializeField] private EnemyType _enemyType; // Slimeか、Turtleか
    [SerializeField] private bool _canMove;  // 動けなくするかどうか
    // Strategyを選択する
    [SerializeField] private TargetSelectStrategyType _strategyType;

    public bool CanMove => _canMove;
    public TargetSelectStrategyType StrategyType => _strategyType;

    public Transform Transform => transform;

  // 移動する(Strategyの選択によりどこへ行くのだろう)
    public void Move(Vector3 delta)
    {
        transform.position += delta;
    }

}

// このクラスは敵(Slime、Turtle)ごとに継承させる。
public class EnemyModel
{
    public int HP { get; protected set; } = 100;
    public int Attack { get; protected set; } = 10;

    public float MoveSpeed { get; protected set; } = 2.0f;

}

public class EnemyPresenter
{
    protected EnemyModel _model;
    protected EnemyView _view;

    // 4体のEnemy群
    private readonly List<EnemySet> _candidates;
    // ストラテジーが、EntryPointから挿入される
    private ITargetSelectStrategy _targetStrategy;
    private Transform _target;

    public EnemyPresenter(
        EnemyModel model,
        EnemyView view,
        List<EnemySet> candidates,
        ITargetSelectStrategy targetStrategy)
    {
        _model = model;
        _view = view;
        _candidates = candidates;
        _targetStrategy = targetStrategy;
    }

  // 敵ループ(EntryPointでUpdateしている)
    public void Tick()
    {
        // オレンジの敵は今は動かしたくないので,,,
        if (!_view.CanMove)
            return;
        
        //
        // ストラテジから追尾対象を選定(ここが大事!)
        //
        _target = _targetStrategy.SelectTarget(_model, _view, _candidates);

        if (_target == null)
            return;

    // 以下移動ロジック
        Vector3 dir = (_target.position - _view.Transform.position);

        if (dir.sqrMagnitude > 0.01f)
        {
            dir.Normalize();

            Vector3 move = dir * _model.MoveSpeed * Time.deltaTime;

            _view.Move(move);
        }
    }

}

EnemyViewにてStrategyと敵タイプ(enum)を選択(インスペクタ上)。EnemyPresenterにて追尾するターゲットを決定するアルゴリズムを変更することにより、Strategyに応じた移動ロジックを発動しています。上記の例では、_targetの中に、選択したStrategyを挿入していることが分かると思います。

public class SlimeModel : EnemyModel
{
    public SlimeModel()
    {
        HP = 50;  // 下のTurtleよりhpが少ない
        Attack = 5;
    }
}

public class TurtleModel : EnemyModel
{
    public TurtleModel()
    {
        HP = 100;  // 上のSlimeよりhpが多い
        Attack = 10;
    }
}

EnemyModelを継承してhpとAttackをSlime、Turtleそれぞれで変更しました。今回の注目点はHPです。「HPが一番少ない敵」を追尾するアルゴリズムがあるため、この戦略を適用すると青いCubeのSlimeはオレンジのCubeのSlimeを狙うことになります。

using System.Collections.Generic;
using UnityEngine;

// 一番HPが低い敵をターゲットに設定するStrategy
public class LowestHpTargetStrategy : ITargetSelectStrategy
{
    public Transform SelectTarget(
        EnemyModel model,
        EnemyView view,
        List<EnemySet> candidates)
    {
        EnemyView best = null;
        int lowestHp = int.MaxValue;

        foreach (var enemy in candidates)
        {
            if (enemy.View == view) continue;

            if (enemy.Model.HP < lowestHp)
            {
                lowestHp = enemy.Model.HP;
                best = enemy.View;
            }
        }

        return best != null ? best.Transform : null;
    }
}

// 一番近い敵をターゲットにするStrategy
public class NearestTargetStrategy : ITargetSelectStrategy
{
    public Transform SelectTarget(
        EnemyModel model,
        EnemyView view,
        List<EnemySet> candidates)
    {
        Transform best = null;
        float bestDist = float.MaxValue;

        foreach (var enemy in candidates)
        {
            if (enemy.View == view) continue;

            float dist =
                (enemy.View.Transform.position - view.Transform.position)
                .sqrMagnitude;

            if (dist < bestDist)
            {
                bestDist = dist;
                best = enemy.View.Transform;
            }
        }

        return best;
    }
}

お待たせしました。上記が今回組んだ2つのStrategy「一番近い敵を狙う」戦略と、「一番HPが低い敵を狙う」戦略です。敵全員のModelとViewを持っているので、これらの情報から、HPと、positionを取得しています。両方ともメソッドの引数で受け取っている参照が同じなのが分かります。今考えたらList<EnemySet>はコンストラクタで受け取っても良かったかもしれません。

結果

二つの戦略「一番近い敵を狙う」ものと、「一番HPが低い敵を狙う」ものを適用してみて差を比較します。

一番近い敵を狙う戦略を適用

まず、青のCube(Slime)にアタッチしてあるEnemyViewのインスペクタから戦略を「Nearest」にし、戦略を変更させて動かしてみます。

青のCubeはどの敵へ向かうのでしょうか。

正解はもちろん画像右のCubeです。

一番HPが低い敵を狙う戦略を適用

続いて、戦略を「Lowest HP」にして実行してみます。適用すれば一番HPが低い敵に向かうはずです。

この中で一番HPが低いのは真ん中のSlimeなのでそのまま直進しました。

以上から、綺麗に戦略の分岐が構築できていることが分かります。

結局参照注入の問題はどうなったのか

前にStrategyのメソッドを通じて参照を渡す場合、各Strategyに渡せる参照が固定になるという問題点を説明しました。利用側が単一クラスであれば別にコンストラクタで全部渡せばいいのですが、今回のように同一クラスの複数のContext(EnemyPresenter)で使用する時、_model(EnemyModel)や_view(EnemyView)などは、そのContextのインスタンスごとで変わってくるため、メソッドを通じて渡してあげる必要があります。また今回のサンプルコードを見てみると各Stategyのコンストラクタに具体クラスとか入っています。実装しといてなんですが、これでは追加のStrategyを作成するとき、また追加の参照をContextで入れて、そのたびにすべてのStrategyで追加記述して受け取ることになる可能性があります。そんなこと一々やってられません。なので、この問題を解決するためにインターフェースを通してStrategyに情報を渡すクラスを作成することを提案します。

このようにすることで、DataProviderにて公開されている情報を自由に使うことができ、その使う、使わないも自由に選択することが出来るようになります。

一々このDataProviderに格納されている情報を更新する必要はありますが、追加の戦略を作成するときは確実に楽になります。

さいごに

今回はStrategyパターンを学んでいきました。私の理解と見解でこの記事を書いているので、有識者がいれば教えてください。今日インターンで、初めから設計パターンをどう使うのかを考えるのではなく、起こっている問題を認識してから、適用していくということを言われました。つまり、なぜそのような設計にしているのか根本を理解することが大事のようです。意外と言語化するって難しいなと思います。聞かれたときに相手を納得させる理論を作っていきましょう。ばいばい。

コメントを残す

CAPTCHA