【Unity】バトルゲームで抽象クラスを使ってみる

2024年8月28日

簡易的な2人対戦バトルゲームを通して、抽象クラスを学んでいきます。

目次

プロジェクトの作成とマネージャの基本動作

2Dプロジェクトを作成してください。

オブジェクトの配置

2つのキャラクタのみの格闘ゲームを想定してみましょう。
今回は、ターン制(交互に攻撃を繰り返す)として考えてみます。
リアルタイムであれば、当たり判定を使って相手に攻撃を与える方法になります。ターン制の場合もコントロールする審判がいることになりますが、大体同じ作りにすることが出来ます。

構成をシンプルにするために、2つの四角のオブジェクトを作成し、これを2つのキャラクタに見立てます。

2DのSquare(四角形)

2DオブジェクトからSquare(四角形)を1つ作成します。

同様に、2つ目(さらにもう1つ)作成します。
それぞれ、次のようにTransformコンポーネントを設定してください。
Positionだけを変更しています。

Square 1つ目の四角形

Square(1) 2つ目の四角形

GameObject

ゲーム進行をコントロールするためのマネージャ(審判)を作成します。

スクリプト

GameManager.cs

ゲーム進行をコントロールするためのスクリプトになります。
今回は、スペースキーを押すごとに交互にプレイヤーが攻撃するようにしてみましょう。

では、最初にスペースキーを押すコードを記述しましょう。

using UnityEngine;

public class GameManager : MonoBehaviour
{
    void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {

        }
    }
}

次に交互に攻撃するために、今、どちらが攻撃しているかを保存しておくフィールドを作成しておきます。

int selectPlayer;

0が代入されていると、Player1
1が代入されていると、Player2
の攻撃ターンが選択されていることにします。

using UnityEngine;

public class GameManager : MonoBehaviour
{
    int selectPlayer;

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            if (selectPlayer == 0)
            {
                // プレイヤー1の攻撃
            }
            else
            {
                // プレイヤー2の攻撃
            }
        }
    }
}

このままでは、いつまでもプレイヤー1の攻撃になりますので、フィールドselectPlayerの値が変化するようにします。
プレイヤー1の攻撃が完了したら、プレイヤー2の攻撃に変更されるように、またその逆も登録します。

using UnityEngine;

public class GameManager : MonoBehaviour
{
    int selectPlayer;

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            if (selectPlayer == 0)
            {
                // プレイヤー1の攻撃
                Debug.Log("Player1の攻撃");
                selectPlayer = 1;
            }
            else
            {
                // プレイヤー2の攻撃
                Debug.Log("Player2の攻撃");
                selectPlayer = 0;
            }
        }
    }
}

これで、Updateメソッドが呼ばれるたび(つまり、1フレームごと)にスペースキーが押されたかを判定し、押されたらプレイヤーが交互に攻撃できるようになりました。

では、これをGameObjectにアタッチしておきましょう。

実行結果

プレイヤーの攻撃

次にプレイヤー1の攻撃を作成してみましょう。
攻撃のメソッドを考えてみます。

public void Attack()
{
    // 攻撃する
}

結果をわかりやすくするために、コンソールにログを表示するようにしてみます。

public void Attack()
{
    // 攻撃する
    Debug.Log("Player1の攻撃");
}

スクリプト

Player1.cs

Squareオブジェクト(Player1)が攻撃を実行できるように、スクリプトを作成しましょう。

using UnityEngine;

public class Player1 : MonoBehaviour
{
    public void Attack()
    {
        // 攻撃する
        Debug.Log("Player1の攻撃");
    }
}

Squareオブジェクト(Player1)にアタッチします。

それでは、このAttackメソッドをGameManagerスクリプトから呼び出すようにしましょう。

GameManager.cs

Player1スクリプトのAttackメソッドは、Squareゲームオブジェクトにアタッチされています。まず、そのゲームオブジェクトを取得するところから始めます。

GameObject playerObj1;

void Start()
{
    playerObj1 = GameObject.Find("Square");
}

これでplayer1フィールドにSquareゲームオブジェクトの参照が代入されました。
続いて、取得できたゲームオブジェクトのPlayer1コンポーネント(スクリプトはPlayer1.cs)を取得してplayer変数に代入してみましょう。
コンポーネントは、実態としては、クラスのインスタンスです。取得する型は、Player1クラスになります。

Player1 player = playerObj1.GetComponent<Player1>()

取得したインスタンスのAttackメソッドを呼び出してみます。

player.Attack();

2つの文を並べると、

Player1 player = playerObj1.GetComponent<Player1>();
player.Attack();

になります。
ここで、player変数は、今後使わないので、インテリセンスを使って、インラインの一時変数に変更します。
次のようになります。

playerObj1.GetComponent<Player1>().Attack();

では、クラスのコードとしてまとめてみましょう。

全コード

using UnityEngine;

public class GameManager : MonoBehaviour
{
    int selectPlayer;

    GameObject playerObj1;

    void Start()
    {
        playerObj1 = GameObject.Find("Square");
    }

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            if (selectPlayer == 0)
            {
                // プレイヤー1の攻撃
                //Debug.Log("Player1の攻撃");
                playerObj1.GetComponent<Player1>().Attack();

                selectPlayer = 1;
            }
            else
            {
                // プレイヤー2の攻撃
                Debug.Log("Player2の攻撃");
                selectPlayer = 0;
            }
        }
    }
}

実行結果

うまくいったら、Player2側も同様に実装してみましょう。

Player1とPlayer2を反映させたコード

GameManager.cs

using UnityEngine;

public class GameManager : MonoBehaviour
{
    int selectPlayer;

    GameObject playerObj1;
    GameObject playerObj2;

    void Start()
    {
        playerObj1 = GameObject.Find("Square");
        playerObj2 = GameObject.Find("Square (1)");
    }

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            if (selectPlayer == 0)
            {
                // プレイヤー1の攻撃
                //Debug.Log("Player1の攻撃");
                playerObj1.GetComponent<Player1>().Attack();

                selectPlayer = 1;
            }
            else
            {
                // プレイヤー2の攻撃
                //Debug.Log("Player2の攻撃");
                playerObj2.GetComponent<Player2>().Attack();
                selectPlayer = 0;
            }
        }
    }
}

Player1.cs

using UnityEngine;

public class Player1 : MonoBehaviour
{
    public void Attack()
    {
        // 攻撃する
        Debug.Log("Player1の攻撃");
    }
}

Player2.cs

using UnityEngine;

public class Player2 : MonoBehaviour
{
    public void Attack()
    {
        // 攻撃する
        Debug.Log("Player2の攻撃");
    }
}

相手にダメージを与えるようにしてみましょう

攻撃するメソッドの呼び出しはうまくできましたか?
動作に問題ないことを確認したら、次へ進みましょう。

それぞれのプレイヤーに初期のHpを与えるようにしましょう。

public int hp = 10;

このような感じでしょうか?
フィールドのhpに10を初期値として代入します。

クラスにまとめると次のようになります。

Player1.cs

using UnityEngine;

public class Player1 : MonoBehaviour
{
    public int hp = 10;

    public void Attack()
    {
        // 攻撃する
        Debug.Log("Player1の攻撃");
    }
}

このhpは、プレイヤー2に攻撃された時、減らされるものとします。
プレイヤー2に攻撃された時、次のコードを実行するようにします。

playerObj1.GetComponent<Player1>().hp -= 1;

Debug.Log($"player1のHPは、{playerObj1.GetComponent<Player1>().hp}");

GameManager.cs

では、プレイヤー2の攻撃に合わせて、上記コードを追加します。

else
{
    // プレイヤー2の攻撃
    //Debug.Log("Player2の攻撃");
    playerObj2.GetComponent<Player2>().Attack();
    playerObj1.GetComponent<Player1>().hp -= 1;

    Debug.Log($"player1のHPは、{playerObj1.GetComponent<Player1>().hp}");

    selectPlayer = 0;
}

全コード

GameManager.cs

using UnityEngine;

public class GameManager : MonoBehaviour
{
    int selectPlayer;

    GameObject playerObj1;
    GameObject playerObj2;

    void Start()
    {
        playerObj1 = GameObject.Find("Square");
        playerObj2 = GameObject.Find("Square (1)");
    }

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            if (selectPlayer == 0)
            {
                // プレイヤー1の攻撃
                //Debug.Log("Player1の攻撃");
                playerObj1.GetComponent<Player1>().Attack();
                playerObj2.GetComponent<Player2>().hp -= 1;

                Debug.Log($"player2のHPは、{playerObj2.GetComponent<Player2>().hp}");

                selectPlayer = 1;
            }
            else
            {
                // プレイヤー2の攻撃
                //Debug.Log("Player2の攻撃");
                playerObj2.GetComponent<Player2>().Attack();
                playerObj1.GetComponent<Player1>().hp -= 1;

                Debug.Log($"player1のHPは、{playerObj1.GetComponent<Player1>().hp}");

                selectPlayer = 0;
            }
        }
    }
}

Player1.cs

using UnityEngine;

public class Player1 : MonoBehaviour
{
    public int hp = 10;

    public void Attack()
    {
        // 攻撃する
        Debug.Log("Player1の攻撃");
    }
}

Player2.cs

using UnityEngine;

public class Player2 : MonoBehaviour
{
    public int hp = 10;

    public void Attack()
    {
        // 攻撃する
        Debug.Log("Player2の攻撃");
    }
}

実行結果

クラス図

格闘ゲームなので、キャラクターを入れ替えられるようにしてみましょう

キャラクターを入れ替えられるとは、どのようなことでしょうか?
今は、Player1とPlayer2、共に固定ですね。
常に同じメンバーが戦うことになっています。
これは、これでいいのですが、次にやりたいこととして、対戦メンバーのチェンジに挑戦してみたいですね。

現在のプレイヤーを独立させます

まず、独立したキャラクターなので、SquareとSquare(1)の名前を変更しておきましょう

Squareは、Rank1
Square(1)は、Rank2

としておきましょう

では、入れ替えができるようにPrefabにしておきましょう。

各プレイヤーは、コード実行で作成しますので、ヒエラルキーウィンドウから消しておきます。

スクリプト

このPrefabを代入することで、プレイヤーを選択できるようにしましょう。
public修飾子をつけて、Prefabがアウトレット接続できるようにしておきます。

これまで、記述したコードを生かすためPrefabには、別の名前をつけておきましょう。

public GameObject char1;
public GameObject char2;

アウトレット接続で、ゲームオブジェクトの取得ができますので、Findメソッドは使う必要がありません。
代わりにPrefabからインスタンスを作成するようにします。
また、作成される場所も固定されるようにしておきます。

Instantiate(作成するゲームオブジェクト, 作成する位置, 作成するときの回転);

ここで、回転をQuaternion.identityと指示すると、変更しないことを意味します。

player1 = Instantiate(char1, new Vector3(-1, 0, 0), Quaternion.identity);
player2 = Instantiate(char2, new Vector3(1, 0, 0), Quaternion.identity);

Manager.cs

using UnityEngine;

public class GameManager : MonoBehaviour
{
    int selectPlayer;

    public GameObject char1;
    public GameObject char2;

    GameObject playerObj1;
    GameObject playerObj2;

    void Start()
    {
        //playerObj1 = GameObject.Find("Square");
        //playerObj2 = GameObject.Find("Square (1)");

        playerObj1 = Instantiate(char1, new Vector3(-1, 0, 0), Quaternion.identity);
        playerObj2 = Instantiate(char2, new Vector3(1, 0, 0), Quaternion.identity);
    }

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            if (selectPlayer == 0)
            {
                // プレイヤー1の攻撃
                //Debug.Log("Player1の攻撃");
                playerObj1.GetComponent<Player1>().Attack();
                playerObj2.GetComponent<Player2>().hp -= 1;

                Debug.Log($"player2のHPは、{playerObj2.GetComponent<Player2>().hp}");

                selectPlayer = 1;
            }
            else
            {
                // プレイヤー2の攻撃
                //Debug.Log("Player2の攻撃");
                playerObj2.GetComponent<Player2>().Attack();
                playerObj1.GetComponent<Player1>().hp -= 1;

                Debug.Log($"player1のHPは、{playerObj1.GetComponent<Player1>().hp}");

                selectPlayer = 0;
            }
        }
    }
}

prefabをアウトレット接続しましょう

実行結果

実行中の次のことを確認します。

Rank1とRank2のゲームオブジェクトが生成されていること
動作に問題がないこと

新規プレイヤーを今後作っていくとき、決まったフォーマットで作ってもらうようにしておきたい

2つのプレイヤーを作成しましたが、今後もっと作りたいですよね。
その場合、なんでも自由に作ってもらっていいでしょうか?
違いますよね。例えば、今回の場合だと、Prefabをアウトレット接続するので、条件にあったものじゃないといけません。

次の条件が必要ですね。

Hp フィールドを持っていること
Attackメソッドを持っていること

Hp フィールドに関しても全てのキャラクターに共通でいいですね。
Attackメソッドは、キャラクタごとにコードが違ってもいいですね。

さて、条件がわかったところで、作ってもらう場合、いちいち、このことを口頭で伝えますか?また、自分で作るにしても毎回思い出すようにしますか?

そこで、これを解決するために、抽象クラスを継承した個別のキャラクタクラスに変更します。

抽象クラスにすると、このクラス自体のインスタンスを作ることは禁止できます。
修飾子の、abstractをつけて、抽象クラスであることを宣言します。

では、hpを宣言してみましょう

using UnityEngine;

public abstract class CharaBase : MonoBehaviour
{
    public int hp;
}

次に個別のプレイヤーに実装してほしい Attackメソッドを宣言します。
abstract 就職誌がついたメソッドで、シグネチャー(宣言の行)で完了させます。
つまり、;(セミコロン)で行を終わらせます。

CharaBase.cs(キャラクタのベースになる基本クラス)

using UnityEngine;

public abstract class CharaBase : MonoBehaviour
{
    public int hp;

    public abstract void Attack();
}

では、Player1クラスは、CharaBase抽象クラスを継承するように変更しましょう。

hpの宣言は、抽象クラスで宣言されているので、そちらで設定できるので不要になります。
Attackメソッドは、作成を強制されます。override修飾子をつけます。

Player1.cs

using UnityEngine;

public class Player1 : CharaBase
{
    //public int hp = 10;

    public override void Attack()
    {
        // 攻撃する
        Debug.Log("Player1の攻撃");
    }
}

Player2も同様に変更します。

Player2.cs

using UnityEngine;

public class Player2 : CharaBase
{
    //public int hp = 10;

    public override void Attack()
    {
        // 攻撃する
        Debug.Log("Player2の攻撃");
    }
}

実行結果

変更前と違いがないことを確認します。

クラス図

新しく作るキャラクタは、抽象クラスの形式に合うものとしたい

さて、新しく作るキャラクタは、条件指定にすることが出来ました。
ただ、GameManagerスクリプトへのアウトレット接続では、GameObject型の宣言になっているので、ゲームオブジェクトであれば、なんでもアウトレット接続できてしまいますね。
そこで、抽象クラスのCharaBaseクラスを継承していることが条件にしてみましょう。

次のように宣言を変更します。

//public GameObject char1;
//public GameObject char2;

public CharaBase char1;
public CharaBase char2;

このように変更すると、アウトレット接続するゲームオブジェクトは、ChraBaseクラスまたは、CharaBaseクラスの派生クラスのスクリプトがアタッチされていることが条件になります。

ただ、気づかれたと思いますが、Instantiateメソッドがエラーになっています。
Instantiateメソッドは、ジェネリックメソッドなので、第一引数がGameObjectに固定されておらず、Objectクラスであればいい(intなど組み込みを除く)、つまり、なんでもOKなのです。
気をつける点としては、戻り値の型も第一引数の型と同じ型になるということですね。

public class Component : UnityEngine.Object
{
    public T GetComponent<T>()
    {
        // Unityエンジンの実装コードが記述されているところ
    }
}
//GameObject playerObj1;
//GameObject playerObj2;

CharaBase player1;
CharaBase player2;

実行結果

動作に違いがないことを確認します。

クラス図

GameManagerスクリプトからは、Player1クラス、Player2クラスは、見えていません。
見えているのは、CharBase抽象クラスのみになります。

この状態をGameManagerはCharBase抽象クラスへ依存

といいます。
Player1クラスやPlayer2クラスへの依存を断ち切って、依存性を下げることに成功しました。

コードの整理整頓

サンプルの目的は達成できたのではないでしょうか!

次に、コードの整理を進めていきたいと思います。
一応動作はしていますが、綺麗にまとめることはできないでしょうか?

動作の変更を伴わないコードの整理のことを、リファクタリングといいます。

CharaBase抽象クラスを アウトレット接続の宣言に伴う整理

player1、player2フィールドには、クラスのインスタンスが代入されています。(GetComponentで、スクリプトを取得と同じ段階)
なので、GameManegerスクリプトでは、次の変更ができます。

//playerObj1.GetComponent<Player1>().Attack();
player1.Attack();

この文を変更して、すぐ実行してみてください。
動作に違いがないこと、エラーにならないことを確認します。
このように、都度確認し、不具合が発生した時、絞る範囲を縮めることが大切です。

クラス名を整理

現在、Rank1プレファブにアタッチされているスクリプト名は、Player1になっています。
いまいち、マッチしていないので、これをRank1に変更しましょう。
影響の範囲の考慮をインテリセンスに任せましょう。

右クリックして名前の変更を選びます。

続いて、ファイル名も合わせましょう。

Prefabも「スクリプトが見つからない」となりますので、再度アウトレット接続し、更新します。

Hpには、10を代入しておきましょう

更新したPrefabを代入します

もう一度、動作検証しておきましょう。

GameManager.cs

using UnityEngine;

public class GameManager : MonoBehaviour
{
    int selectPlayer;

    //public GameObject char1;
    //public GameObject char2;

    public CharaBase char1;
    public CharaBase char2;

    //GameObject playerObj1;
    //GameObject playerObj2;

    CharaBase player1;
    CharaBase player2;

    void Start()
    {
        //playerObj1 = GameObject.Find("Square");
        //playerObj2 = GameObject.Find("Square (1)");

        player1 = Instantiate(char1, new Vector3(-1, 0, 0), Quaternion.identity);
        player2 = Instantiate(char2, new Vector3(1, 0, 0), Quaternion.identity);
    }

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            if (selectPlayer == 0)
            {
                // プレイヤー1の攻撃
                //Debug.Log("Player1の攻撃");
                //playerObj1.GetComponent<Player1>().Attack();
                player1.Attack();
                //playerObj2.GetComponent<Player2>().hp -= 1;
                player2.hp -= 1;

                //Debug.Log($"player2のHPは、{player2.GetComponent<Player2>().hp}");
                Debug.Log($"player2のHPは、{player2.hp}");

                selectPlayer = 1;
            }
            else
            {
                // プレイヤー2の攻撃
                //Debug.Log("Player2の攻撃");
                //playerObj2.GetComponent<Player2>().Attack();
                player2.Attack();
                //playerObj1.GetComponent<Player1>().hp -= 1;
                player1.hp -= 1;

                //Debug.Log($"player1のHPは、{char1.GetComponent<Player1>().hp}");
                Debug.Log($"player1のHPは、{player1.hp}");

                selectPlayer = 0;
            }
        }
    }
}

Rank1.cs

using UnityEngine;

public class Rank1 : CharaBase
{
    //public int hp = 10;

    public override void Attack()
    {
        // 攻撃する
        Debug.Log("Player1の攻撃");
    }
}

Rank2.cs

using UnityEngine;

public class Rank2 : CharaBase
{
    //public int hp = 10;

    public override void Attack()
    {
        // 攻撃する
        Debug.Log("Player2の攻撃");
    }
}

いかがでしょうか?

うまく動作しましたか?

状態管理(ステートマシン)をコルーチンで構成してみます。

次のラムダ式は、引数なしでKeyDownを検出するものになります。
!がついていますので、そうでない場合(つまり入力されていない場合)で、戻り値はbool値になります。

() => !Input.GetKeyDown(KeyCode.Space)

これをWaitWhileメソッドの引数にします。
これにより、「Keyが押されなければ、ここで待つ」コードが完成です。

yield return new WaitWhile(() => !Input.GetKeyDown(KeyCode.Space));

具体的には、次のようなコードになります。

GameManager.cs(基本)

使わなくなったコードのコメント行は削除しています。

using System.Collections;
using UnityEngine;

public class GameManager : MonoBehaviour
{
    public CharaBase char1;
    public CharaBase char2;

    CharaBase player1;
    CharaBase player2;

    void Start()
    {
        player1 = Instantiate(char1, new Vector3(-1, 0, 0), Quaternion.identity);
        player2 = Instantiate(char2, new Vector3(1, 0, 0), Quaternion.identity);

        StartCoroutine(StateMachine());
    }

    IEnumerator StateMachine()
    {
        while (true)
        {
            yield return new WaitWhile(() => !Input.GetKeyDown(KeyCode.Space));

            // プレイヤー1の攻撃
            player1.Attack();
            player2.hp -= 1;
            Debug.Log($"player2のHPは、{player2.hp}");

            yield return null;

            yield return new WaitWhile(() => !Input.GetKeyDown(KeyCode.Space));

            // プレイヤー2の攻撃
            player2.Attack();
            player1.hp -= 1;
            Debug.Log($"player1のHPは、{player1.hp}");

            yield return null;
        }
    }
}

CharaBase.cs(キャラクタのベースになる基本クラス)

using UnityEngine;

public abstract class CharaBase : MonoBehaviour
{
    public int hp;

    public abstract void Attack();
}

Rank1.cs

using UnityEngine;

public class Rank1 : CharaBase
{
    public override void Attack()
    {
        // 攻撃する
        Debug.Log("Player1の攻撃");
    }
}

Rank2.cs

using UnityEngine;

public class Rank2 : CharaBase
{
    public override void Attack()
    {
        // 攻撃する
        Debug.Log("Player2の攻撃");
    }
}

クラス図

ゲームの進行のカスタマイズ

GameManagerの更新で、機能を追加することが出来ます。

GameManager.cs(各プレイヤーの振る舞いをメソッドにする)

using System.Collections;
using UnityEngine;

public class GameManager : MonoBehaviour
{
    public CharaBase char1;
    public CharaBase char2;

    CharaBase player1;
    CharaBase player2;

    void Start()
    {
        player1 = Instantiate(char1, new Vector3(-1, 0, 0), Quaternion.identity);
        player2 = Instantiate(char2, new Vector3(1, 0, 0), Quaternion.identity);

        StartCoroutine(StateMachine());
    }

    IEnumerator StateMachine()
    {
        while (true)
        {
            yield return new WaitWhile(() => !Input.GetKeyDown(KeyCode.Space));

            Player1Turn();

            yield return null;
            yield return new WaitWhile(() => !Input.GetKeyDown(KeyCode.Space));

            Player2Turn();

            yield return null;
            yield break;
        }
    }

    void Player1Turn()
    {
        // プレイヤー1の攻撃
        player1.Attack();
        player2.hp -= 1;
        Debug.Log($"player2のHPは、{player2.hp}");
    }

    void Player2Turn()
    {
        // プレイヤー2の攻撃
        player2.Attack();
        player1.hp -= 1;
        Debug.Log($"player1のHPは、{player1.hp}");
    }
}

GameManager.cs(ゲームオーバーを考える)

コルーチンで、別のコルーチンの終了を待つことが出来ます。

    IEnumerator Start()
    {
        player1 = Instantiate(char1, new Vector3(-1, 0, 0), Quaternion.identity);
        player2 = Instantiate(char2, new Vector3(1, 0, 0), Quaternion.identity);

        yield return StartCoroutine(StateMachine());

        Debug.Log("Geme Over処理");
    }

実行中のコルーチンから戻ってくるのは、

yield break;

で可能です。

全てのコード

using System.Collections;
using UnityEngine;

public class GameManager : MonoBehaviour
{
    public CharaBase char1;
    public CharaBase char2;

    CharaBase player1;
    CharaBase player2;

    IEnumerator Start()
    {
        player1 = Instantiate(char1, new Vector3(-1, 0, 0), Quaternion.identity);
        player2 = Instantiate(char2, new Vector3(1, 0, 0), Quaternion.identity);

        yield return StartCoroutine(StateMachine());

        Debug.Log("Geme Over処理");
    }

    IEnumerator StateMachine()
    {
        while (true)
        {
            yield return new WaitWhile(() => !Input.GetKeyDown(KeyCode.Space));

            Player1Turn();

            yield return null;
            yield return new WaitWhile(() => !Input.GetKeyDown(KeyCode.Space));

            Player2Turn();

            yield return null;
            yield break;
        }
    }

    void Player1Turn()
    {
        // プレイヤー1の攻撃
        player1.Attack();
        player2.hp -= 1;
        Debug.Log($"player2のHPは、{player2.hp}");
    }

    void Player2Turn()
    {
        // プレイヤー2の攻撃
        player2.Attack();
        player1.hp -= 1;
        Debug.Log($"player1のHPは、{player1.hp}");
    }
}

GameManager.cs(イベント処理に対応)

次のような基本的な使い方を、Player1とPlayer2で構築します。

// イベントの宣言
public UnityEvent Player1Handler;

// イベントの登録
Player1Handler.AddListener(Player1Turn);

// イベントの実行
Player1Handler.Invoke();

// イベントハンドラ
void Player1Turn()
{
    // プレイヤー1の攻撃
    player1.Attack();
    player2.hp -= 1;
    Debug.Log($"player2のHPは、{player2.hp}");
}

全てのコード

using System.Collections;
using UnityEngine;
using UnityEngine.Events;

public class GameManager : MonoBehaviour
{
    public UnityEvent Player1Handler;
    public UnityEvent Player2Handler;

    public CharaBase char1;
    public CharaBase char2;

    CharaBase player1;
    CharaBase player2;

    IEnumerator Start()
    {
        Player1Handler.AddListener(Player1Turn);
        Player2Handler.AddListener(Player2Turn);

        player1 = Instantiate(char1, new Vector3(-1, 0, 0), Quaternion.identity);
        player2 = Instantiate(char2, new Vector3(1, 0, 0), Quaternion.identity);

        yield return StartCoroutine(StateMachine());

        Debug.Log("Geme Over処理");
    }

    IEnumerator StateMachine()
    {
        while (true)
        {
            yield return new WaitWhile(() => !Input.GetKeyDown(KeyCode.Space));

            Player1Handler.Invoke();

            yield return null;
            yield return new WaitWhile(() => !Input.GetKeyDown(KeyCode.Space));

            Player2Handler.Invoke();

            yield return null;

            // 特定条件が揃うとゲームエンドにする場合
            // yield Break;
        }
    }

    void Player1Turn()
    {
        // プレイヤー1の攻撃
        player1.Attack();
        player2.hp -= 1;
        Debug.Log($"player2のHPは、{player2.hp}");
    }

    void Player2Turn()
    {
        // プレイヤー2の攻撃
        player2.Attack();
        player1.hp -= 1;
        Debug.Log($"player1のHPは、{player1.hp}");
    }
}

クラス図

状態管理(ステートマシン)と、プレイヤーのアクションを各クラスに分類しましょう。

GameManager.cs(プレイヤーの管理を担当)

using UnityEngine;

public class GameManager : MonoBehaviour
{
    public StateMachine stateMachine;

    public CharaBase char1;
    public CharaBase char2;

    CharaBase player1;
    CharaBase player2;

    void Start()
    {
        player1 = Instantiate(char1, new Vector3(-1, 0, 0), Quaternion.identity);
        player2 = Instantiate(char2, new Vector3(1, 0, 0), Quaternion.identity);

        stateMachine.Player1Handler.AddListener(Player1Turn);
        stateMachine.Player2Handler.AddListener(Player2Turn);
    }

    void Player1Turn()
    {
        // プレイヤー1の攻撃
        player1.Attack();
        player2.hp -= 1;
        Debug.Log($"player2のHPは、{player2.hp}");
    }

    void Player2Turn()
    {
        // プレイヤー2の攻撃
        player2.Attack();
        player1.hp -= 1;
        Debug.Log($"player1のHPは、{player1.hp}");
    }
}

StateMachine.cs(状態管理のみ担当)

using System.Collections;
using UnityEngine;
using UnityEngine.Events;

public class StateMachine : MonoBehaviour
{
    public UnityEvent Player1Handler;
    public UnityEvent Player2Handler;

    IEnumerator Start()
    {
        yield return StartCoroutine(Loop());

        Debug.Log("Geme Over処理");
    }

    IEnumerator Loop()
    {
        while (true)
        {
            yield return new WaitWhile(() => !Input.GetKeyDown(KeyCode.Space));

            Player1Handler.Invoke();

            yield return null;
            yield return new WaitWhile(() => !Input.GetKeyDown(KeyCode.Space));

            Player2Handler.Invoke();

            yield return null;

            // 特定条件が揃うとゲームエンドにする場合
            // yield Break;
        }
    }
}

クラス図

オブジェクトの配置

実行結果

ゲームループの終了を外部からコントロール

GameManager.cs(ゲームループの終了も管理)

ループの終了をStopCoroutineメソッドで実行できるようにします。

using System.Collections;
using UnityEngine;

public class GameManager : MonoBehaviour
{
    public StateMachine stateMachine;

    public CharaBase char1;
    public CharaBase char2;

    CharaBase player1;
    CharaBase player2;

    Coroutine GameLoop;

    IEnumerator Start()
    {
        player1 = Instantiate(char1, new Vector3(-1, 0, 0), Quaternion.identity);
        player2 = Instantiate(char2, new Vector3(1, 0, 0), Quaternion.identity);

        stateMachine.Player1Handler.AddListener(Player1Turn);
        stateMachine.Player2Handler.AddListener(Player2Turn);

        StartGameLoop();

        yield return StartCoroutine(GameEndCheck());

        Debug.Log("GameEnd");
    }

    void StartGameLoop()
    {
        GameLoop = StartCoroutine(stateMachine.Loop());
    }

    void StopGameLoop()
    {
        StopCoroutine(GameLoop);
    }

    IEnumerator GameEndCheck()
    {
        while (true)
        {
            yield return new WaitWhile(() => !Input.GetKeyDown(KeyCode.E));

            StopGameLoop();

            yield break;
        }
    }

    void Player1Turn()
    {
        // プレイヤー1の攻撃
        player1.Attack();
        player2.hp -= 1;
        Debug.Log($"player2のHPは、{player2.hp}");
    }

    void Player2Turn()
    {
        // プレイヤー2の攻撃
        player2.Attack();
        player1.hp -= 1;
        Debug.Log($"player1のHPは、{player1.hp}");
    }
}

StateMachine.cs(Startメソッドを排除し、外部から起動、終了できるようにする)

using System.Collections;
using UnityEngine;
using UnityEngine.Events;

public class StateMachine : MonoBehaviour
{
    public UnityEvent Player1Handler;
    public UnityEvent Player2Handler;

    public IEnumerator Loop()
    {
        while (true)
        {
            yield return new WaitWhile(() => !Input.GetKeyDown(KeyCode.Space));

            Player1Handler.Invoke();

            yield return null;
            yield return new WaitWhile(() => !Input.GetKeyDown(KeyCode.Space));

            Player2Handler.Invoke();

            yield return null;
        }
    }
}

クラス図

実行結果

アクションのイベントを各クラスに委譲する

GameManager.cs(ゲームループの終了も管理)

ループの終了をStopCoroutineメソッドで実行できるようにします。

using System.Collections;
using UnityEngine;

public class GameManager : MonoBehaviour
{
    public StateMachine stateMachine;

    public CharaBase char1;
    public CharaBase char2;

    CharaBase player1;
    CharaBase player2;

    Coroutine GameLoop;

    IEnumerator Start()
    {
        player1 = Instantiate(char1, new Vector3(-1, 0, 0), Quaternion.identity);
        player2 = Instantiate(char2, new Vector3(1, 0, 0), Quaternion.identity);

        stateMachine.Player1Handler.AddListener(() => player1.Attack(player2));
        stateMachine.Player2Handler.AddListener(() => player2.Attack(player1));

        StartGameLoop();

        yield return StartCoroutine(GameEndCheck());

        Debug.Log("GameEnd");
    }

    void StartGameLoop()
    {
        GameLoop = StartCoroutine(stateMachine.Loop());
    }

    void StopGameLoop()
    {
        StopCoroutine(GameLoop);
    }

    IEnumerator GameEndCheck()
    {
        while (true)
        {
            yield return new WaitWhile(() => !Input.GetKeyDown(KeyCode.E));

            StopGameLoop();

            yield break;
        }
    }
}

CharaBase.cs(キャラクタのベースになる基本クラス)

using UnityEngine;

public abstract class CharaBase : MonoBehaviour
{
    public int hp;

    public abstract void Attack(CharaBase other);
}

Rank1.cs(1人目のキャラクタ)

using UnityEngine;

public class Rank1 : CharaBase
{
    public override void Attack(CharaBase other)
    {
        // 攻撃する
        Debug.Log("Player1の攻撃");
        other.hp -= 1;
        Debug.Log($"{other.name} HP = {other.hp}");
    }
}

Rank2.cs(2人目のキャラクタ)

using UnityEngine;

public class Rank2 : CharaBase
{
    public override void Attack(CharaBase other)
    {
        // 攻撃する
        Debug.Log("Player2の攻撃");
        other.hp -= 1;
        Debug.Log($"{other.name} HP = {other.hp}");
    }
}

StateMachine.cs(Startメソッドを排除し、外部から起動、終了できるようにする)

using System.Collections;
using UnityEngine;
using UnityEngine.Events;

public class StateMachine : MonoBehaviour
{
    public UnityEvent Player1Handler;
    public UnityEvent Player2Handler;

    public IEnumerator Loop()
    {
        while (true)
        {
            yield return new WaitWhile(() => !Input.GetKeyDown(KeyCode.Space));

            Player1Handler.Invoke();

            yield return null;
            yield return new WaitWhile(() => !Input.GetKeyDown(KeyCode.Space));

            Player2Handler.Invoke();

            yield return null;
        }
    }
}

クラス図

実行結果

アイテムの追加

持っているアイテムによって、対戦に影響を与えるようにしましょう。
アイテムは、装備として複数持てるようにします。
アイテムの種類は、武器と防具としましょう。

武器の追加

武器を新しく装備できるようにしましょう。
武器には、名前(Name)と攻撃力(Atk)の要素を持つようにします。

Weapon.cs

public class Weapon
{
    public string Name { get; set; }

    public int Atk { get; set; }
}

装備に武器を追加します。
ただし、複数持てるようにするため、Listにしておきます。

Equipment.cs

Equipment(装備)を管理するクラスです。Weaposクラスの情報を複数持てるようにしています。
外部から、このリストに追加するため、 AddWeaponメソッドも追加しておきましょう。

using System.Collections.Generic;

public class Equipment
{
    List<Weapon> weapons = new List<Weapon>();

    public void AddWeapon(Weapon weapon)
    {
        weapons.Add(weapon);
    }
}

GameManager.cs

プレイヤーに装備します。
GameManagerでプレイヤーを生成した後、次の実装コードを追加します。

Weapon weapon = new Weapon();
weapon.Name = "斧";
weapon. Atk = 2 ;

player1.equipment.AddWeapon(weapon);

初期化子を使って、

Weapon weapon = new Weapon
{
    Name = "斧",
    Atk = 2
};

player1.equipment.AddWeapon(weapon);

まとめると、

using System.Collections;
using UnityEngine;

public class GameManager : MonoBehaviour
{
    public StateMachine stateMachine;

    public CharaBase char1;
    public CharaBase char2;

    CharaBase player1;
    CharaBase player2;

    Coroutine GameLoop;

    IEnumerator Start()
    {
        player1 = Instantiate(char1, new Vector3(-1, 0, 0), Quaternion.identity);

        Weapon weapon = new Weapon
        {
            Name = "斧",
            Atk = 2
        };

        player1.equipment.AddWeapon(weapon);

        player2 = Instantiate(char2, new Vector3(1, 0, 0), Quaternion.identity);

        stateMachine.Player1Handler.AddListener(() => player1.Attack(player2));
        stateMachine.Player2Handler.AddListener(() => player2.Attack(player1));

        StartGameLoop();

        yield return StartCoroutine(GameEndCheck());

        Debug.Log("GameEnd");
    }

    void StartGameLoop()
    {
        GameLoop = StartCoroutine(stateMachine.Loop());
    }

    void StopGameLoop()
    {
        StopCoroutine(GameLoop);
    }

    IEnumerator GameEndCheck()
    {
        while (true)
        {
            yield return new WaitWhile(() => !Input.GetKeyDown(KeyCode.E));

            StopGameLoop();

            yield break;
        }
    }
}

武器の攻撃力を攻撃するときに反映させる

今は、攻撃すると相手のHpを1減らすこととなっていますが、武器の攻撃力分だけ減るようにしてみましょう

Equipment.cs

Weaponスクリプトに選択されている武器を取得するコードを追加しておきます。
これは、将来、複数の武器を携帯しているときに、どの武器を選択するかを選べるようにしておくことを目的にしています。

public Weapon GetSelectedWeapon()
{
    return weapons[0];
}

コード全体では、

using System.Collections.Generic;

public class Equipment
{
    List<Weapon> weapons = new List<Weapon>();

    public void AddWeapon(Weapon weapon)
    {
        weapons.Add(weapon);
    }

    public Weapon GetSelectedWeapon()
    {
        return weapons[0];
    }
}

Rank1.cs

それでは、攻撃した時のコードを更新してみましょう
other.hp -= 1; のところを選んだ武器の攻撃力分だけ減少するコードにしていみます。

other.hp -= equipment.GetSelectedWeapon().Atk;

まとめると、

using UnityEngine;

public class Rank1 : CharaBase
{
    public override void Attack(CharaBase other)
    {
        // 攻撃する
        Debug.Log("Player1の攻撃");

        other.hp -= equipment.GetSelectedWeapon().Atk;

        Debug.Log($"{other.name} HP = {other.hp}");
    }
}

実行結果

これまで、Hpの残りが9だったのが8になっているのがわかると思います。

GameManager.cs

Player2も同じようにしましょう。

weapon = new Weapon
{
    Name = "刀",
    Atk = 3
};

player2.equipment.AddWeapon(weapon);

まとめると、

using System.Collections;
using UnityEngine;

public class GameManager : MonoBehaviour
{
    public StateMachine stateMachine;

    public CharaBase char1;
    public CharaBase char2;

    CharaBase player1;
    CharaBase player2;

    Coroutine GameLoop;

    IEnumerator Start()
    {
        player1 = Instantiate(char1, new Vector3(-1, 0, 0), Quaternion.identity);

        Weapon weapon = new Weapon
        {
            Name = "斧",
            Atk = 2
        };

        player1.equipment.AddWeapon(weapon);

        player2 = Instantiate(char2, new Vector3(1, 0, 0), Quaternion.identity);

        weapon = new Weapon
        {
            Name = "刀",
            Atk = 3
        };

        player2.equipment.AddWeapon(weapon);

        stateMachine.Player1Handler.AddListener(() => player1.Attack(player2));
        stateMachine.Player2Handler.AddListener(() => player2.Attack(player1));

        StartGameLoop();

        yield return StartCoroutine(GameEndCheck());

        Debug.Log("GameEnd");
    }

    void StartGameLoop()
    {
        GameLoop = StartCoroutine(stateMachine.Loop());
    }

    void StopGameLoop()
    {
        StopCoroutine(GameLoop);
    }

    IEnumerator GameEndCheck()
    {
        while (true)
        {
            yield return new WaitWhile(() => !Input.GetKeyDown(KeyCode.E));

            StopGameLoop();

            yield break;
        }
    }
}

Rank2.cs

using UnityEngine;

public class Rank2 : CharaBase
{
    public override void Attack(CharaBase other)
    {
        // 攻撃する
        Debug.Log("Player2の攻撃");

        other.hp -= equipment.GetSelectedWeapon().Atk;

        Debug.Log($"{other.name} HP = {other.hp}");
    }
}

実行結果

player2の攻撃で、player1のHpが3減っているのが分かりますね。

クラス図

まとめ

まだまだ、道半ばですが、大きく設計を変更することなく、機能の追加ができるようになりました。
当初、直接それぞれのスクリプトが呼び出しあっていましたが、分離することができるようになりましたね。
なるべく、各スクリプト(クラス)が依存し合わないように設計することがコツです。

ソースコード

Unity バージョン 2021.1.0f1

Unity

Posted by hidepon