Unityでマインスイーパーを作ろう 2

マインスイーパーを作ろう

前回からの続きです。画面上に指定個数分セルを生成するところまで作りました。今回はその続きです。

完成予定のゲームとソース

完成するゲームは以下のページで遊べます。

Unity WebGL Player | Minesweeper

ソースはGitHub上にあります。

gamegame-game/Minesweeper
Unity Game, Minesweeper. Contribute to gamegame-game/Minesweeper development by creating an account on GitHub.

Cell クラスに展開処理、マーク処理を実装する

早速ですが、Cellクラスの処理を実装していきます。いまのところ実装は何もない状態のはずです。

Cellクラスは、セルの状態を保持します。「爆弾の有無」や「セルがどういう状態なのか(展開済やマークがつけられているなど)」、「隣接する爆弾の数」が必要です。

またCellクラスは、クリックされたらセルの状態を展開された状態にします。同様に右クリック時には、マークが付けられた状態にしなければなりません。

それぞれの処理を実装したのが以下になります。

using System;
using UnityEngine;
using UnityEngine.UI;

public class Cell : MonoBehaviour
{
    // セル1辺の大きさ
    public static int Size = 30;

    // セルの状態
    public CellState State { get; set; }

    // このセルに爆弾があるかどうか
    public bool IsBomb { get; set; }

    // 隣接する爆弾の数です
    public int NeighbourBombCount { get; set; }

    // 隣接するセルを展開するメソッドです
    public Action OpenNeighbourCell { get; private set; }

    // セルを初期化します
    public void Initialize(bool isBomb, Action openNeighbourCell)
    {
        this.State = CellState.Closing;
        this.IsBomb = isBomb;
        this.OpenNeighbourCell = openNeighbourCell;
    }

    // セルを展開します
    public bool Open()
    {
        // すでに展開済の場合は何もしません
        // フラグが立ててあるセルも展開しないようにします
        if (this.State == CellState.Opened || this.State == CellState.Flag)
            return true;

        // Textコンポーネントを取得しておく
        var text = this.GetComponentInChildren<Text>();
        if (this.IsBomb)
        {
            // 爆弾セルを開いたのでゲームオーバー
            text.text = "X";
            return false;
        }
        else
        {
            // セルを展開済の状態に更新し、白色にします
            this.State = CellState.Opened;
            this.GetComponent<Image>().color = Color.white;
            if (this.NeighbourBombCount == 0)
            {
                // 爆弾0なので空文字表示にします
                text.text = string.Empty;
                // 隣接する爆弾0のセルなので周りのセルを展開します
                this.OpenNeighbourCell();
            }
            else
            {
                // 隣接する爆弾の数を表示します
                text.text = this.NeighbourBombCount.ToString();
            }
            return true;
        }
    }

    // セルにマークを付けます
    public void ChangeMark()
    {
        var text = this.GetComponentInChildren<Text>();
        switch (this.State)
        {
            case CellState.Closing:
                this.State = CellState.Flag;
                // フラグは適当な文字で代替してます
                text.text = "●"; 
                break;
            case CellState.Flag:
                this.State = CellState.Question;
                text.text = "?";
                break;
            case CellState.Question:
                this.State = CellState.Closing;
                text.text = string.Empty;
                break;
            default:
                break;
        }
    }
}

// フィールド上のセルがどのような状態にあるかを表します
public enum CellState
{
    Closing = 0,    // セルが閉じられた状態
    Opened = 1,     // セルが展開された状態
    Flag = 2,       // セルにフラグが立てられた状態
    Question = 3    // セルに?が付けられた状態
}

ちょっと長いですが、順番に説明します。

プロパティ各種

State

CellStateという列挙体でセルの状態を管理します。セルは以下の4つがあります。

  • Closing はセルが展開されていない状態
  • Opend はセルがすでに展開された状態
  • Flag は右クリックでセルにフラグが立てられた状態
  • Question は右クリックでセルに?が付けられた状態

IsBomb

このセルに爆弾が存在するかどうかです。

NeighbourBombCoun

このセルが隣接するセルに存在する爆弾の個数です。

OpenNeighbourCell

隣接するセルを展開するためのデリゲートです。デリゲートとは、メソッドそのものの変数みたいなものです。

セルクラスは自分のセルが隣接するセルの存在を知らないので、初期化時に外からデリゲートでもらいます。

メソッド各種

Initialize(bool isBomb, Action openNeighbourCell)

セルを初期化するためのメソッドです。「このセルに爆弾が存在するかどうか」と「このセルに隣接するセルを展開するためのデリゲート」をもらいます。

また生成時にはセルはすべて展開されていない状態なので、CellState.Closing とします。

Open()

このセルを展開するためのメソッドです。戻り値で展開処理に成功したか、失敗したかを返します。爆弾が存在すれば展開失敗です。

ただしすでに展開済だった場合、何もせずに展開成功とします。また、セルにフラグが立てられている状態の場合、誤爆しないように展開はしません。

ゲームのルールとして、隣接するセルに爆弾が存在しない場合は、展開したセルに隣接するセルをすべて自動で展開します。OpenNeighbourCell デリゲートを実行します。

OpenNeighbourCell デリゲートでは、隣接するセルの Openメソッドが実行されるので、一気にセルの展開処理が行われます。

展開されたらボタンを白色に、隣接するセルの色を表示します。

具体的には以下のようなイメージです。

白色が展開済のセルで、水色が未展開のセルです。数字のない白色は隣接する爆弾が0個なので、自動的に展開されて一気にセルが開きます。

ChangeMark()

未展開のセルには右クリックでマークを付けられます。現在の状態から順番にマークが付け変わるようにしています。

Fieldクラスに各種メソッドを実装する

Field.cs のメソッドを以下のように実装します。

// フィールドの初期化(爆弾位置)を行います。
public void Initialize(int row, int col, int bombCount)
{
    this.Cells = new Cell[row, col];

    // ランダム生成された爆弾位置の配列
    var randomBombFlags = Enumerable.Concat(
        Enumerable.Repeat(true, bombCount),
        Enumerable.Repeat(false, row * col - bombCount)
        ).OrderBy(_ => Guid.NewGuid()).ToArray();

    // 生成されたランダム位置に爆弾フラグ設定
    int i = 0;
    for (int r = 0; r < this.Cells.GetLength(0); r++)
    {
        for (int c = 0; c < this.Cells.GetLength(1); c++)
        {
            this.Cells[r, c] = GenerateCell(r, c, randomBombFlags[i++]);
        }
    }

    // 隣接する爆弾を数えておく
    for (int r = 0; r < this.Cells.GetLength(0); r++)
    {
        for (int c = 0; c < this.Cells.GetLength(1); c++)
        {
            this.Cells[r, c].NeighbourBombCount = 
          this.Cells[r, c].NeighbourBombCount = 
                    GetNeighbourCells(r, c)
                    .Count(cell => cell.IsBomb);
        }
    }

    // フィールドサイズの調整
    this.GetComponent<RectTransform>().sizeDelta = new Vector2(col * Cell.Size, row * Cell.Size);
}

// セルを盤面に初期化・生成します。
private Cell GenerateCell(int r, int c, bool isBomb)
{
    // ゲームオブジェクトを画面に配置します。
    var go = Instantiate(CellPrefab, this.GetComponent<RectTransform>());
    go.GetComponent<RectTransform>().anchoredPosition = new Vector2(Cell.Size / 2 + Cell.Size * c, Cell.Size / 2 + Cell.Size * r);

    // セルクラスを初期化します。
    var cell = go.GetComponent<Cell>();
    cell.Initialize(isBomb, () => OpenNeighbourCell(r, c));

    return cell;
}

// 隣接するセルをすべて取得します。
private Cell[] GetNeighbourCells(int r, int c)
{
    var cells = new List<Cell>();

    var isTop = r == 0;
    var isButtom = r == this.Cells.GetLength(0) - 1;
    var isLeft = c == 0;
    var isRight = c == this.Cells.GetLength(1) - 1;

    // 左上
    if (!isTop && !isLeft) cells.Add(this.Cells[r - 1, c - 1]);
    // 上
    if (!isTop) cells.Add(this.Cells[r - 1, c]);
    // 右上
    if (!isTop && !isRight) cells.Add(this.Cells[r - 1, c + 1]);
    // 右
    if (!isRight) cells.Add(this.Cells[r, c + 1]);
    // 右下
    if (!isButtom && !isRight) cells.Add(this.Cells[r + 1, c + 1]);
    // 下
    if (!isButtom) cells.Add(this.Cells[r + 1, c]);
    // 左下
    if (!isButtom && !isLeft) cells.Add(this.Cells[r + 1, c - 1]);
    // 左の判定
    if (!isLeft) cells.Add(this.Cells[r, c - 1]);

    return cells.ToArray();
}

// あるセルの隣接するセルすべてを展開します。
private void OpenNeighbourCell(int r, int c)
{
    foreach (var cell in GetNeighbourCells(r, c))
    {
        // 展開済のセルには何もしません
        if (cell.State != CellState.Opened) cell.Open();
    }
}

Initialize(int row, int col, int bombCount)

Initializeメソッドはすでに作成しているものですが、爆弾位置をランダムに設定する処理がなかったの作成しています。

7-10行目が爆弾位置を決めるための処理です。やっていることはセルの個数分のbool型配列に爆弾個数分の true を詰め込みます。それをランダムに並べ替えてシャッフルしています。それを生成時に順番に設定しています。

Enumerable.Concatで引数の配列を連結しています。連結する配列は、Enumerable.Repeat(true, bombCount)が爆弾の個数分の true のみの配列、Enumerable.Repeat(false, row * col – bombCount)が爆弾のないセルの個数分のfalseだけの配列です。

それをOrderBy(_ => Guid.NewGuid())でシャッフルしています。

あとはすべてのセルが初期化されたあと、隣接する爆弾の個数を数えるメソッドで、隣接する爆弾の数を数えています。

GenerateCell(int r, int c, bool isBomb)

これもすでに作成しているメソッドですが、cell.Initializeメソッドを呼び出し、セルの初期化を行っています。引数には爆弾の有無と、隣接するセルすべてを展開するための処理のデリゲートを渡しています。

GetNeighbourCells(int r, int c)

引数はセルの行番号と列番号です。

このメソッドは隣接するセルを配列で取得します。やたらと冗長な処理ですが、大体のセルは周囲8つのセルがありますが、四隅のセルだと隣接するセルは3つだけになったりします。配列の範囲を越えないように、それを数えています。

多分行数と列数を1つずつ増やした配列を予め用意しておけば、配列の範囲外を参照しないためのIF文のコードは削減できるかもしれません。

OpenNeighbourCell(int r, int c)

最後は、隣接するセルのOpen処理を呼び出すメソッドです。引数は行番号と列番号です。

GetNeighbourCellsメソッドで隣接するセルをすべて取得し、そのそれぞれにOpenメソッドを呼び出しています。

注意点はすでに展開済のセルには呼び出しを行わないという点です。こうしないと、隣接するセル同士が順々に互いのOpen処理を呼び続けてスタックオーバーフローが起こってしまいます。

クリックと右クリックイベントを作成

最後にクリックで Cell の Openメソッドを呼び出せるようにします。同じように右クリックで Cell の ChangeMarkメソッドを呼び出せるようにします。

ゲームオーバーとクリアの判定も作成します。

まとめてどうぞ。すべて Fieldクラスに実装します。

// クリックを監視してマークします
private void Update()
{
    RightClickCell();
    ClickCell();
}

// セルのクリックを判定します。
private void ClickCell()
{
    if (Input.GetMouseButtonDown(0))
    {
        var clickPoint = Input.mousePosition;
        var collider = Physics2D.OverlapPoint(clickPoint);
        if (collider)
        {
            var go = collider.transform.gameObject;

            // クリックされたらセルを展開します。
            var result = go.GetComponent<Cell>()?.Open();

            // 爆弾だった場合ゲームオーバー
            if (result == false)
            {
                GameOver();
            }
            else
            {
                // 展開に成功した場合、爆弾以外のすべてのセルが展開済の場合クリア
                if (this.Cells.Cast<Cell>().Where(cell => !cell.IsBomb)
                    .All(cell => cell.State == CellState.Opened))
                {
                    GameClear();
                }
            }
        }
    }
}

// セルの右クリックを判定します。
private void RightClickCell()
{
    if (Input.GetMouseButtonDown(1))
    {
        var clickPoint = Input.mousePosition;
        var collider = Physics2D.OverlapPoint(clickPoint);
        if (collider)
        {
            var go = collider.transform.gameObject;
            go.GetComponent<Cell>()?.ChangeMark();
        }
    }
}

// ゲームオーバー時の処理です。
private void GameOver()
{
    Debug.Log("ゲームオーバー");
}

// ゲームオーバー時の処理です。
private void GameClear()
{
    Debug.Log("ゲームクリア");
}

クリックと右クリックの判定

Updateメソッドの中で、Input.GetMouseButtonDownメソッドを呼び出して毎回クリックされているかを判定しています。

引数が “0” で左クリック、”1″ で右クリックです。このときのマウス座標に重なって存在しているコライダーを取得します。そのコライダーからCellを取り出して、それぞれ処理を呼び出しています。

クリック時には Open メソッドで当該セルを展開します。爆弾だった場合、ゲームオーバーです。展開に成功した場合、爆弾が存在しないすべてのセルが展開されたかどうかでクリア処理を事項します。

完成

これで完成です。クリア時とゲームオーバー時にはログを出しているだけですが、適宜処理を実装すればいい感じになると思います。

ソースはGitHub上にあります。各クラスの実装をを確認していただければと思います。

gamegame-game/Minesweeper
Unity Game, Minesweeper. Contribute to gamegame-game/Minesweeper development by creating an account on GitHub.

遊んで見る

完成するゲームは以下のページで遊べます。

Unity WebGL Player | Minesweeper

こんな感じで遊べます。クリックで展開し、右クリックで爆弾と思しき場所にマークしてます。

以上です。

コメント