【Unity】第一回 オセロAI開発 〜ゲーム基盤とランダムに置くAIの作成〜【ゲームAI】

Unity
マイケル
マイケル
みなさんこんにちは!
マイケルです!
エレキベア
エレキベア
こんにちクマ〜〜
マイケル
マイケル
突然だけど題名にもある通り、
今日からはしばらくオセロAIの開発を進めていくよ!
エレキベア
エレキベア
オセロクマか〜〜〜
単純だからAI作成入門によさそうクマね
マイケル
マイケル
まさしくAI作成の方に集中できそうだよね!
とりあえず今回は準備としてオセロゲームの基盤を作ったから
実装した内容について簡単に紹介していくよ!
01 Reversi
エレキベア
エレキベア
これは中々ちゃんとしてるクマね
マイケル
マイケル
それと今回はランダムに石を置くAIまでしか作成していないけど、
次回以降、下記のような有名なアルゴリズムもいくつか実装する予定だよ!
  • MiniMax法
  • モンテカルロ法
  • 何らかの機械学習手法
エレキベア
エレキベア
なんか聞いたことはあるクマね
楽しみクマ〜〜〜
マイケル
マイケル
そして強いAIを作るだけじゃつまらないので、上記アルゴリズムをミックスしながら
キャラクターと実際に対戦しているような仕組みも作ってみようと思っています。
作ったゲームは公開するまでを目標に頑張ってみます!
エレキベア
エレキベア
エレキベアも是非参戦させてほしいクマ〜〜〜
スポンサーリンク

ゲーム全体構成

マイケル
マイケル
それでは全体の構成を見ていきます!
GitHubにも上げていますが、アセットをいくつも使用しているため
今回はソースコードのみ公開としています。
参考程度にお使いください!

GitHub – masarito617/unity-reversi-game-scripts

エレキベア
エレキベア
ライセンス周りが面倒くさいクマからね

検証環境

マイケル
マイケル
検証環境は下記の通り!


[Unityバージョン]
2021.3.1f1 LTS


[使用パッケージ]
UniRx
UniTask
VContainer
DOTween
Japanese School Classroom

エレキベア
エレキベア
UniRx、VContainerと流行りのアセットが盛り沢山クマね
マイケル
マイケル
簡単なゲームだし練習がてら使ってみようと思ってね!
それとJapanese School Classroomは有料のアセットだけど、クオリティの高い教室が手に入って汎用性も高いからおすすめです!!

エレキベア
エレキベア
何か前も使ってたクマね
マイケル
マイケル
たくさん使わせていただいてます!

スクリプト構成

マイケル
マイケル
各クラス、パッケージの関係性としてはざっくりと下記のようになっています!
VContainerやUniRxを使用した構成で、DIコンテナからクラスを注入したり、UI側からManagersの状態を監視して更新するようにしています。
01 UML↑パッケージの関係性
パッケージ役割
LifeTimeScopesクラスの注入やエントリーポイントの指定
EntryPoints全体の処理のエントリーポイント
Managersゲーム全体やプレイヤー、石といったオブジェクトの管理
UIsUIの表示
PlayersプレイヤーとAIに関連する処理
Stones石と関連する処理
マイケル
マイケル
まだまだ甘いところはあるかと思いますが、
下記書籍のUniRx、Zenjectの章や、とりすーぷさんのコードを参考にさせていただきました!いつも勉強させていただいてます!

Unityゲーム プログラミング・バイブル 2nd Generation

参考記事:
グローバルゲームジャムでクラス設計をやった話2020

エレキベア
エレキベア
UniRx、UniTaskの書籍を出した方クマね
マイケル
マイケル
LifeTimeScope、EntryPointのコードは下記のようになっています。
この辺りの詳細について知りたい方は、VContainerの公式チュートリアルをご参照ください!

公式チュートリアル:
Hello World | VContainer

using Reversi.EntryPoints;
using Reversi.Managers;
using Reversi.Players.Input;
using Reversi.Players.Input.Impl;
using Reversi.UIs;
using UnityEngine;
using VContainer;
using VContainer.Unity;

namespace Reversi.LifeTimeScopes
{
    /// <summary>
    /// Gameシーン LifeTimeScope
    /// </summary>
    public class GameLifeTimeScope : LifetimeScope
    {
        [SerializeField] private GamePresenter gamePresenter;

        protected override void Configure(IContainerBuilder builder)
        {
            // 各クラスを登録
            builder.Register<GameManager>(Lifetime.Singleton);
            builder.Register<BoardManager>(Lifetime.Singleton);
            builder.Register<StoneManager>(Lifetime.Singleton);
            builder.Register<PlayerManager>(Lifetime.Singleton);
            builder.Register<IInputEventProvider, InputEventProvider>(Lifetime.Singleton);

            // コンポーネント登録
            builder.RegisterComponent(gamePresenter);

            // エントリーポイント登録
            builder.RegisterEntryPoint<GameEntryPoint>();
        }
    }
}
↑依存関係、エントリーポイントの定義
using Reversi.Managers;
using VContainer;
using VContainer.Unity;

namespace Reversi.EntryPoints
{
    /// <summary>
    /// Gameシーン EntryPoint
    /// </summary>
    public class GameEntryPoint : IStartable, ITickable
    {
        private readonly GameManager _gameManager;

        [Inject]
        public GameEntryPoint(GameManager gameManager)
        {
            _gameManager = gameManager;
        }

        void IStartable.Start()
        {
            _gameManager.OnStart();
        }

        void ITickable.Tick()
        {
            _gameManager.OnUpdate();
        }
    }
}
↑用意されたインターフェースを使用することでUnityEngineを使わずにライフサイクルを定義できる
エレキベア
エレキベア
これがいわゆるDIコンテナというやつクマね
マイケル
マイケル
そしてUI(Presenter)からの監視は下記のようになっています。
Managerクラス側にReactivePropertyを定義してその状態を監視するシンプルな構成です。
    /// <summary>
    /// ストーン管理クラス
    /// </summary>
    public class StoneManager
    {
        /// <summary<
        /// 各色ごとのストーンの数
        /// </summary<
        private readonly ReactiveProperty<int> _whiteStoneCount = new(0);
        private readonly ReactiveProperty<int> _blackStoneCount = new(0);
        public IReadOnlyReactiveProperty<int> WhiteStoneCount => _whiteStoneCount;
        public IReadOnlyReactiveProperty<int> BlackStoneCount => _blackStoneCount;

・・・略・・・

    }
↑ReactivePropertyの定義
using Reversi.Managers;
using TMPro;
using UniRx;
using UnityEngine;
using VContainer;

namespace Reversi.UIs
{
    /// <summary>
    /// GamePresenter
    /// </summary>
    public class GamePresenter : MonoBehaviour
    {
        [Inject] private StoneManager _stoneManager;
        [Inject] private GameManager _gameManager;
        [SerializeField] private TextMeshProUGUI blackCountText;
        [SerializeField] private TextMeshProUGUI whiteCountText;
        [SerializeField] private TextMeshProUGUI resultText;

        private void Start()
        {
            blackCountText.text = "0";
            whiteCountText.text = "0";
            resultText.text = "";

            // ストーン数表示
            _stoneManager
                .BlackStoneCount
                .Subscribe(x => blackCountText.text = x.ToString())
                .AddTo(this);
            _stoneManager
                .WhiteStoneCount
                .Subscribe(x => whiteCountText.text = x.ToString())
                .AddTo(this);

・・・略・・・

        }
    }
}
↑Presenter側で状態を監視してUIを更新する
エレキベア
エレキベア
Manager側からUIを全く気にせずに済むのは気持ちいいクマね

オブジェクト構成

マイケル
マイケル
次はオブジェクトの構成についてです。
シーン内は下記のようになっていて、LifeTimeScope、Presenter、Canvasのオブジェクトを用意してそれぞれアタッチしています。
また、ボードに関しては土台のみ先に置いています。
ScreenShot 2022 06 11 0 45 22↑オブジェクトの準備
マイケル
マイケル
ボードの上のセル(マス)については、下記のようにPrefabを用意してスクリプト内で生成しています。
ScreenShot 2022 06 11 0 46 33↑セルのPrefab
        /// <summary>
        /// ボード生成処理
        /// </summary>
        public void GenerateBoard()
        {
            _cellPositions = new Vector3[CellSideCount, CellSideCount];
            for (var x = 0; x < CellSideCount; x++)
            {
                for (var z = 0; z < CellSideCount; z++)
                {
                    // セル生成
                    var cell = Object.Instantiate(_boardCellPrefab, _boardCellsBase.gameObject.transform);
                    cell.transform.localPosition = new Vector3(x, 0.4f, z);

                    // 位置を保持
                    _cellPositions[x, z] = cell.transform.localPosition;
                }
            }
        }

        /// <summary>
        /// 指定セルの位置を取得
        /// </summary>
        /// <param name="x"></param>
        /// <param name="z"></param>
        /// <returns></returns>
        public Vector3 GetCellPosition(int x, int z)
        {
            return _cellPositions[x, z];
        }
↑セルの生成
マイケル
マイケル
オセロの石についてもPrefabを用意してスクリプトから生成するようにしています。
置ける場合にフォーカスするエリアについても、Prefab内に含めています。
ScreenShot 2022 06 11 0 46 13↑石のPrefab
        /// <summary>
        /// ストーン初期化
        /// </summary>
        /// <param name="cellSideCount">一辺あたりのセル数</param>
        /// <param name="getCellPosition">ボードのセル位置取得処理</param>
        public void InitializeStones(int cellSideCount, Func<int, int, Vector3> getCellPosition)
        {
            // 作成済のストーンを削除
            foreach(Transform child in _stonesBase.transform){
                UnityEngine.Object.Destroy(child.gameObject);
            }

            // ストーンをEmptyで初期化して設定
            _viewStoneCores = new StoneBehaviour[cellSideCount, cellSideCount];
            _stoneStates = new StoneState[cellSideCount, cellSideCount];
            for (var x = 0; x < _viewStoneCores.GetLength(0); x++)
            {
                for (var z = 0; z < _viewStoneCores.GetLength(1); z++)
                {
                    var stone = UnityEngine.Object.Instantiate(_stonePrefab, _stonesBase.transform, true);
                    stone.transform.localPosition = getCellPosition(x, z) + Vector3.up * StoneOffsetY;
                    _viewStoneCores[x, z] = stone.GetComponent<StoneBehaviour>();
                    _viewStoneCores[x, z].SetIndex(x, z);
                    _stoneStates[x, z] = StoneState.Empty;
                }
            }

            // 中央のストーン状態を変更
            var centerIndex1 = cellSideCount / 2;
            var centerIndex2 = centerIndex1 - 1;
            _stoneStates[centerIndex1, centerIndex1] = StoneState.White;
            _stoneStates[centerIndex2, centerIndex1] = StoneState.Black;
            _stoneStates[centerIndex1, centerIndex2] = StoneState.Black;
            _stoneStates[centerIndex2, centerIndex2] = StoneState.White;

            // ストーンの表示を更新
            UpdateAllViewStones(_stoneStates);
        }
↑石の生成処理
マイケル
マイケル
そして、Managerクラスにはメンバ変数として
ボードに対する石の状態配列と、
表示用のオブジェクト配列を持たせています。
状態配列を変更した後に別途表示の更新を行う構成としました。
        /// <summary>
        /// ストーン配列
        /// </summary>
        private StoneState[,] _stoneStates;
        private StoneBehaviour[,] _viewStoneCores; // 表示用
↑状態配列と表示用の配列
    /// <summary>
    /// ストーンの状態
    /// </summary>
    public enum StoneState
    {
        Empty = 0, // 何も無い
        White = 1, // 白
        Black = 2, // 黒
    }
↑石の状態
エレキベア
エレキベア
最後に表示を更新することで見た目上の遅延を少なくしているクマね
マイケル
マイケル
変更後の状態によって石の表示を変える処理は下記になります。
エフェクトやアニメーションもこの時生成するようにしています。
        /// <summary>
        /// ストーンの状態を変更する
        /// </summary>
        /// <param name="changeState">変更するストーン状態</param>
        /// <param name="putStoneIndex">状態変更時に置かれたストーン位置(最初の設定時のみnull)</param>
        public void ChangeViewState(StoneState changeState, StoneIndex putStoneIndex = null)
        {
            // 状態が変わらない場合は何も行わない
            if (_viewState == changeState) return;

            // 状態によって表示を切り替える
            switch (changeState)
            {
                // Emptyの場合は非表示
                case StoneState.Empty:
                    stoneObject.SetActive(false);
                    break;
                // 色が指定されたら表示
                case StoneState.White:
                case StoneState.Black:
                    stoneObject.SetActive(true);
                    // 最終的に設定する位置、回転
                    var targetPosition = Vector3.zero;
                    var targetRotation = new Vector3(0.0f, 0.0f, changeState == StoneState.White ? 0.0f : 180.0f);
                    if (_viewState == StoneState.Empty)
                    {
                        // 初めて置かれる場合
                        stoneObject.transform.localEulerAngles = targetRotation;
                        if (putStoneIndex != null) StartPutEffect();
                    }
                    else
                    {
                        // 色が変わる場合
                        StartTurnAnimation(putStoneIndex, () =>
                        {
                            // 微妙なずれを防ぐためアニメーション完了時に直接指定する
                            stoneObject.transform.localPosition = targetPosition;
                            stoneObject.transform.localEulerAngles = targetRotation;
                        });
                    }
                    break;
            }

            // 状態の設定
            _viewState = changeState;
        }
↑石の表示を変える処理
        /// <summary>
        /// ひっくり返すアニメーション
        /// </summary>
        /// <param name="putStoneIndex"></param>
        /// <param name="callback"></param>
        private void StartTurnAnimation(StoneIndex putStoneIndex, Action callback)
        {
            // 色が変わる場合
            var putVec = Index - putStoneIndex;   // 置いた位置からのベクトル
            var waitTime = putVec.GetLength() * 0.08f; // アニメーションを遅らせる時間(遠いほど開始を遅らせる)
            var q = Quaternion.AngleAxis(180.0f, new Vector3(-putVec.Z, 0.0f, putVec.X)); // 90度回転させたクォータニオン(-Z,Xで指定)

            // ひっくり返るアニメーションを実行
            var sequence = DOTween.Sequence();
            sequence.AppendInterval(waitTime);
            sequence.Append(stoneObject.transform.DOLocalRotateQuaternion(q, 0.5f).SetRelative().SetEase(Ease.OutQuart));
            sequence.Join(DOTween.Sequence()
                .Append(stoneObject.transform.DOLocalMoveY(1, 0.25f).SetRelative().SetEase(Ease.OutQuart))
                .Append(stoneObject.transform.DOLocalMoveY(-1, 0.2f).SetRelative().SetEase(Ease.InOutBounce)));
            sequence.OnComplete(() =>
            {
                callback();
            });
        }
↑アニメーションはDOTWeenで作成
マイケル
マイケル
準備するのはこれだけ!
以上の構成で下記のような画面が出来上がります。
ScreenShot 2022 06 11 0 47 00↑ゲーム実行後の配置
エレキベア
エレキベア
これぞオセロクマ〜〜〜

メイン処理の解説

マイケル
マイケル
ざっくりとした構成が分かったところで、
いよいよオセロのメイン処理を解説していきます。
エレキベア
エレキベア
待ってましたクマ

ストーン関連の処理

マイケル
マイケル
まずはオセロの石(ストーン)関連の処理を見ていきます。
ロジックについては主にStoneCalculator.csにまとめています。
マイケル
マイケル
下記はオセロの肝となる、
「置いた位置からひっくり返せる石を判定する」処理になります。
        /// <summary>
        /// 置いた位置からひっくり返せるストーン情報を返却する
        /// </summary>
        /// <param name="stoneStates">チェックするストーン配列</param>
        /// <param name="putState">置いた色</param>
        /// <param name="putX">置いたX位置</param>
        /// <param name="putZ">置いたZ位置</param>
        /// <returns>ひっくり返せるストーン情報(Index)</returns>
        public static List<StoneIndex> GetTurnStonesIndex(StoneState[,] stoneStates, StoneState putState, int putX, int putZ)
        {
            // 既にストーンが置かれていたら空で返却
            var turnStonesIndex = new List<StoneIndex>();
            if (stoneStates == null || stoneStates[putX, putZ] != StoneState.Empty) return turnStonesIndex;

            // 8方向分のストーンを調べて返却する
            foreach (var searchVec in SearchAllVectors)
            {
                turnStonesIndex.AddRange(GetTurnStonesIndex(stoneStates, putState, putX, putZ, searchVec));
            }
            return turnStonesIndex;
        }

        /// <summary>
        /// 置いた位置から指定方向にひっくり返せるストーン情報を返却する
        /// </summary>
        /// <param name="stoneStates">チェックするストーン配列</param>
        /// <param name="putState">置いた色</param>
        /// <param name="putX">置いたX位置</param>
        /// <param name="putZ">置いたZ位置</param>
        /// <param name="searchVec">調べる方向(-1 or 0 or 1)</param>
        /// <returns>ひっくり返せるストーン情報(Index)</returns>
        private static List<StoneIndex> GetTurnStonesIndex(StoneState[,] stoneStates, StoneState putState, int putX, int putZ, SearchVector searchVec)
        {
            // 置く色と反対の色をターゲットにする
            var targetState = putState == StoneState.Black ? StoneState.White : StoneState.Black;

            // ひっくり返せるストーンを調べる
            var turnStonesIndex = new List<StoneIndex>();
            var x = putX;
            var z = putZ;
            while (true)
            {
                // 調べる方向に進む
                x += searchVec.X;
                z += searchVec.Z;

                // indexが範囲外になったら調査終了
                if (x < 0 || stoneStates.GetLength(0) <= x ||
                    z < 0 || stoneStates.GetLength(1) <= z)
                {
                    break;
                }
                // 何も置いていなければ調査終了
                var stoneState = stoneStates[x, z];
                if (stoneState == StoneState.Empty)
                {
                    break;
                }

                // 置いたストーンと同じ色の場合、調査したストーンを返却する
                if (stoneState == putState)
                {
                    return turnStonesIndex;
                }
                // ターゲットの色だったらリストに追加
                if (stoneState == targetState)
                {
                    turnStonesIndex.Add(new StoneIndex(x, z));
                }
            }
            // 見つからなかったら空で返却
            return new List<StoneIndex>();
        }

        /// <summary>
        /// 探索方向
        /// </summary>
        private static readonly SearchVector[] SearchAllVectors = new SearchVector[]
        {
            new (1,0),   // left
            new (1,1),   // left up diagonal
            new (1,-1),  // left down diagonal
            new (-1,0),  // right
            new (-1,1),  // right up diagonal
            new (-1,-1), // right down diagonal
            new (0,1),   // up
            new (0,-1),  // down
        };
        private class SearchVector
        {
            public int X { get; private set; }
            public int Z { get; private set; }
            public SearchVector(int x, int z)
            {
                // 値をチェックする
                var isValid = false;
                isValid = isValid || (x != 0 && x != 1 && x != -1);
                isValid = isValid || (z != 0 && z != 1 && z != -1);
                isValid = isValid || (x == 0 && z == 0);
                if (isValid) throw new ArgumentException("SearchVectorには0,1,-1のみ指定できます。((0,0)は除く)");

                this.X = x;
                this.Z = z;
            }
        }
↑ひっくり返せる石の判定
マイケル
マイケル
石の配列と置く位置を受け取って8方向分調べています。
もっと効率いい方法もあるかと思いますが、そこはお許しください。。
エレキベア
エレキベア
シンプルな判定クマね
マイケル
マイケル
ちなみに返却しているStoneIndexについては、
下記のように位置情報を格納しているだけのクラスになります。
using System;
using UnityEngine;

namespace Reversi.Stones.Stone
{
    /// <summary>
    /// ストーン配列に対応するインデックス
    /// </summary>
    public class StoneIndex : IEquatable<StoneIndex>
    {
        public int X { get; private set; }
        public int Z { get; private set; }
        public StoneIndex(int x, int z)
        {
            X = x;
            Z = z;
        }

        public float GetLength()
        {
            return Mathf.Sqrt(X * X + Z * Z);
        }

        public static StoneIndex operator+ (StoneIndex a, StoneIndex b)
        {
            return new StoneIndex(a.X + b.X, a.Z + b.Z);
        }

        public static StoneIndex operator- (StoneIndex a, StoneIndex b)
        {
            return new StoneIndex(a.X - b.X, a.Z - b.Z);
        }

        public bool Equals(StoneIndex other)
        {
            if (other == null)
            {
                return false;
            }
            return X == other.X && Z == other.Z;
        }
    }
}
↑石の位置クラス
エレキベア
エレキベア
簡易Vectorクマね
マイケル
マイケル
あとはこの関数を利用して
・置いた後の配列を返却する処理
・置くことが可能な石を返却する処理
も作成しています。
        /// <summary>
        /// 置いた後のストーン状態を返却する
        /// </summary>
        /// <param name="stoneStates"></param>
        /// <param name="putState"></param>
        /// <param name="putX"></param>
        /// <param name="putZ"></param>
        /// <param name="turnStonesIndex"></param>
        /// <returns>置いた後のストーン状態</returns>
        public static StoneState[,] GetPutStoneState(StoneState[,] stoneStates, StoneState putState, int putX, int putZ, List<StoneIndex> turnStonesIndex = null)
        {
            // 既に置いてある場合、そのまま返す
            if (stoneStates == null || stoneStates[putX, putZ] != StoneState.Empty) return stoneStates;

            // ひっくり返せるストーンが指定されていない場合、取得する
            turnStonesIndex ??= GetTurnStonesIndex(stoneStates, putState, putX, putZ);
            if (turnStonesIndex.Count == 0) return stoneStates;

            // 引数からクローンを作成
            var putStoneStates = stoneStates.Clone() as StoneState[,];

            // ストーンを置く
            if (putStoneStates != null)
            {
                putStoneStates[putX, putZ] = putState;

                // ひっくり返す
                foreach (var tuneStone in turnStonesIndex)
                {
                    putStoneStates[tuneStone.X, tuneStone.Z] = putState;
                }
            }
            return putStoneStates;
        }

        /// <summary>
        /// 置くことが可能なストーン状態配列を返却する
        /// </summary>
        /// <param name="stoneStates"></param>
        /// <param name="putState"></param>
        /// <returns> 置くことが可能なストーン状態配列</returns>
        public static List<StoneIndex> GetAllCanPutStonesIndex(StoneState[,] stoneStates, StoneState putState)
        {
            var canPutStones = new List<StoneIndex>();
            for (var x = 0; x < stoneStates.GetLength(0); x++)
            {
                for (var z = 0; z < stoneStates.GetLength(1); z++)
                {
                    // 置けるストーンなら追加
                    if (GetTurnStonesIndex(stoneStates, putState, x, z).Count > 0)
                    {
                        canPutStones.Add(new StoneIndex(x, z));
                    }
                }
            }
            return canPutStones;
        }
↑他のメイン処理
マイケル
マイケル
これでほぼオセロは完成したようなもんですね!
テストも記述しているのでよければこちらもご参照ください!

GitHub – unity-reversi-game-scripts – StoneCalculatorTest.cs

エレキベア
エレキベア
ちょろいもんクマ〜〜

プレイヤー関連の処理

マイケル
マイケル
次は実際にオセロをプレイするプレイヤー関連の処理についてです。
インターフェースとしてIPlayerを用意し、外部からは基本的にターン開始、ターン更新の処理しか呼べなくしています。
using Reversi.Stones.Stone;

namespace Reversi.Players
{
    public interface IPlayer
    {
        /// <summary>
        /// 自分のストーン状態(黒or白)
        /// </summary>
        public StoneState MyStoneState { get; }

        public void StartTurn(StoneState[,] stoneStates);
        public void UpdateTurn();
        public bool IsInputPlayer();
    }
}
↑プレイヤーのインターフェース
マイケル
マイケル
そしてこれを継承したPlayerクラスで具体的な処理を実装しています。
後々作成する入力プレイヤーやAIに共通する処理を記述しています。
using System;
using Reversi.Stones.Stone;

namespace Reversi.Players
{
    /// <summary>
    /// プレイヤー共通クラス
    /// </summary>
    public abstract class Player : IPlayer
    {
        /// <summary>
        /// 自分のストーン状態(黒or白)
        /// </summary>
        protected readonly StoneState MyStoneState;
        StoneState IPlayer.MyStoneState => MyStoneState;

        /// <summary>
        /// ストーン配列(AIチェック用)
        /// </summary>
        protected StoneState[,] StoneStates;

        /// <summary>
        /// 選択したストーン
        /// 継承先のクラスでこの変数に設定する
        /// </summary>
        protected StoneIndex SelectStoneIndex;

        /// <summary>
        /// ストーン選択処理
        /// </summary>
        private readonly Action<StoneState, int, int> _putStoneAction;

        protected Player(StoneState myStoneState, Action<StoneState, int, int> putStoneAction)
        {
            _putStoneAction = putStoneAction;
            MyStoneState = myStoneState;
        }

        /// <summary>
        /// ターン開始
        /// </summary>
        /// <param name="stoneStates"></param>
        public void StartTurn(StoneState[,] stoneStates)
        {
            // ストーン配列を設定して初期化
            StoneStates = stoneStates;
            SelectStoneIndex = null;

            // 思考開始
            StartThink();
        }

        /// <summary>
        /// ターン更新
        /// </summary>
        public void UpdateTurn()
        {
            // 思考更新
            UpdateThink();

            // ストーンが選択されたら置く
            if (SelectStoneIndex == null) return;
            _putStoneAction(MyStoneState, SelectStoneIndex.X, SelectStoneIndex.Z);
            SelectStoneIndex = null; // 初期化
        }

        /// <summary>
        /// 思考処理
        /// </summary>
        protected virtual void StartThink() { }
        protected virtual void UpdateThink() { }

        /// <summary>
        /// 入力プレイヤーかどうか?
        /// </summary>
        public virtual bool IsInputPlayer()
        {
            // 基本的にfalse
            return false;
        }
    }
}
マイケル
マイケル
AI含む各プレイヤークラスではこのクラスを継承して、
StartThink()、UpdateThink()をオーバーライドすることで実装しています。
エレキベア
エレキベア
Template Methodパターンクマね
マイケル
マイケル
入力するプレイヤークラスは下記の通り!
UpdateTurn()をオーバーライドして入力検知した石を設定しています。
using System;
using Reversi.Stones.Stone;

namespace Reversi.Players.Input
{
    /// <summary>
    /// 入力プレイヤー
    /// </summary>
    public class InputPlayer : Player
    {
        private readonly IInputEventProvider _inputEventProvider;

        public InputPlayer(StoneState myStoneState, Action<StoneState, int, int> putStoneAction, IInputEventProvider inputEventProvider) : base(myStoneState, putStoneAction)
        {
            _inputEventProvider = inputEventProvider;
        }

        protected override void UpdateThink()
        {
            // 入力検知したストーンを取得
            var selectStone = _inputEventProvider.GetSelectStone();
            if (selectStone != null)
            {
                SelectStoneIndex = selectStone.Index;
            }
        }

        public override bool IsInputPlayer()
        {
            return true;
        }
    }
}
↑入力検知した石を設定する
エレキベア
エレキベア
各プレイヤーのクラスはだいぶシンプルになるクマね

ランダムに置くAIの処理

マイケル
マイケル
そして最後にランダムに石を置くAIを作成します!
置くことが可能な石を取得する処理を使ってランダムに返すようにしています。
using System;
using Cysharp.Threading.Tasks;
using Reversi.Stones;
using Reversi.Stones.Stone;

namespace Reversi.Players.AI
{
    /// <summary>
    /// ランダムにストーンを置くAI
    /// </summary>
    public class RandomAIPlayer : Player
    {
        public RandomAIPlayer(StoneState myStoneState, Action<StoneState, int, int> putStoneAction) : base(myStoneState, putStoneAction) { }

        protected override void StartThink()
        {
            StartThinkAsync();
        }

        /// <summary>
        /// 選択するストーンを考える
        /// </summary>
        private async void StartThinkAsync()
        {
            // 考える時間
            await UniTask.Delay(500);

            // ランダムに取得して設定
            var canPutStones = StoneCalculator.GetAllCanPutStonesIndex(StoneStates, MyStoneState);
            var randomIndex = UnityEngine.Random.Range(0, canPutStones.Count);
            SelectStoneIndex = canPutStones[randomIndex];
        }
    }
}
↑ランダムに置くAI
エレキベア
エレキベア
これもだいぶシンプルにまとまったクマ〜〜
マイケル
マイケル
以上でオセロゲームの基盤は完成です!!

おわりに

マイケル
マイケル
というわけで今回はオセロゲーム基盤の作成でした!
どうだったかな??
エレキベア
エレキベア
設計もちゃんと考えて作るのは楽しかったクマ〜〜
今後ちゃんとしたAIを作るのが楽しみクマ
マイケル
マイケル
シンプルながら作って楽しかったね!
次回はオセロAI定番の、MiniMaxアルゴリズムを使ったAIの作成だ!
お楽しみに〜〜!
エレキベア
エレキベア
クマ〜〜〜〜

【Unity】第一回 オセロAI開発 〜ゲーム基盤とランダムに置くAIの作成〜【ゲームAI】〜完〜

次回の記事はこちら!

コメント