Unityで作ろう!ゲームアルゴリズム(1) マインスイーパのアルゴリズム
skill

Unityで作ろう!ゲームアルゴリズム(1)
マインスイーパのアルゴリズム

2016.10.04

秋の長雨が続いておりますが、皆様いかがお過ごしでしょうか。
筆者は趣味で少しだけ楽器を触ったりするのですが、長雨の湿気で楽器ケースやアンプにカビさんが大繁殖して衝撃を受けました。(楽器本体は無事でしたので、まだ良かったです・・・)
技術と同じく、楽器もマメに触らないとダメになっちゃうものですね。

今回から新連載「Unityで作ろう!ゲームアルゴリズム」をスタートさせていただきます。
本連載では、様々なゲームのアルゴリズムや、ゲームでよく使う仕組みなどをUnity + C#を使って実装していきますよ!
Unityの基本をご存知の方々を対象に進めさせていただきますので、「Unityってなんぞやホイ?」と思われた方は、本連載の前に ゼロからのUnity をご一読ください。

さて、記念すべき第1回は、シンプルなゲームでかるーく学んでいきましょう。
ということで、今回はかの有名な「マインスイーパ」のアルゴリズムを作ってみます。

賀好 昭仁

■マインスイーパって?

読者の皆様は恐らくご存知かと思いますが、フィールドに散りばめられた地雷(マイン)を除去(スイープ)するゲームです。

地雷を避けながらマス目を開いていくだけのシンプルなゲームですが、ヒマな時についつい遊んじゃうんですよね。
Windowsを初めて触った時、とりあえず最初から入っているゲームを遊んでみようとして存在を知った方も多いハズ。

また、近年ではソーシャルゲームになったり宝探し的な要素が追加されたりなど、ちょっと味付けされた色々なバージョンが出ていたりします。

もしプレイされたことが無いのであれば、Google先生に「マインスイーパ」と聞くと無料で遊べるマインスイーパがたくさん出てきますので、ぜひ遊んでみてください。

■マインスイーパのルールを整理してみよう

まずはゲームのルールをある程度整理しておきましょう。
簡単な箇条書きでも、あるのと無いのとでは開発のしやすさが全く変わってきます。

マインスイーパのルールをざっくりと考えてみますと、

・正方形のマスが縦横に並んでいる
縦横のサイズはゲームや難易度によってまちまち。

・初期状態では、マスは全て伏せられている

・マスには地雷が埋まっていることがある
一般的に地雷が多いと難しく、地雷が少ないと簡単になります。

・プレイヤーは任意のマスを開くことができる
1手目はヒントが無いので、いきなりゲームオーバーになってしまうこともあります。
これを避けるため、最近は1手目でゲームオーバーにならないよう工夫されているようです。(今回は工夫せず、ベタに実装します)

・地雷のあるマスを開くとゲームオーバー
ゲームオーバーになった時は、地雷のあるマスが自動的に全部開きます。

・地雷の無いマスを開くと、そのマスに隣接(タテ・ヨコ・ナナメ)している地雷の数が表示される
マインスイーパにおいて一番重要な情報です。
急に「5」とか出てくるとヒヤっとします。

・地雷が隣接していないマスを開くと、その周囲のマスを自動的に開く
沢山のマスが一気に開くと気持ちいいですよね。

・開いてないマスには「地雷があると思われる場所」のチェックを付けられる
チェックを付けたマスは、クリックしても開けなくした方が親切です。

・地雷以外のマスを全て開くとステージクリア
ちょっとしたステージクリアの演出が必要です。

といったところでしょうか。

■プログラムを見てみよう

前述のルールに沿って、プログラムを書いてみました。

2ファイル合わせて300行程度ですが、マインスイーパの基本的なルールはひと通り網羅できているかと思います。

各プログラムには要所要所にコメントを記載しましたので、目を通してみてください。

・GameScene_Controller.cs
ゲームシーンのコントローラクラスです。

各マスの状態を管理し、マスから入力情報を受け取ってゲームのハンドリングを行います。
マインスイーパのアルゴリズムはこちらのクラスに詰まっています

using UnityEngine;
using System.Collections;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using System;
using System.Collections.Generic;
using System.Linq;

/// <summary>
/// ゲームシーンコントローラ
/// </summary>
public class GameScene_Controller : MonoBehaviour
{
  public enum Modes
  {
    // マスを開くモード
    Open,
    // マスをチェック状態にするモード
    Check
  }

  /// <summary>
  /// マスの操作モード
  /// </summary>
  /// <value>The mode.</value>
  public Modes Mode { get { return mode; } }

  /// <summary>
  /// ゲームクリアしたかどうか
  /// </summary>
  /// <value><c>true</cy> if this instance is game clear; otherwise, <c>false</cy>.</value>
  bool IsGameClear { get { return gameClearText.activeSelf; } }

  /// <summary>
  /// ゲームオーバーになったかどうか
  /// </summary>
  /// <value><c>true</cy> if this instance is game over; otherwise, <c>false</cy>.</value>
  bool IsGameOver { get { return gameOverText.activeSelf; } }

  [SerializeField]
  Size size;
  [SerializeField]
  Button setOpenButton;
  [SerializeField]
  Button setCheckButton;
  [SerializeField]
  GridLayoutGroup cellContainer;
  [SerializeField]
  GameObject gameClearText;
  [SerializeField]
  GameObject gameOverText;
  [SerializeField]
  GameScene_Cell cellPrefab;

  Modes mode;
  List<GameScene_Cell> cells = new List<GameScene_Cell>();

  void Start()
  {
    setOpenButton.onClick.AddListener(OnSetOpenButtonClick);
    setCheckButton.onClick.AddListener(OnSetCheckButtonClick);
    OnSetOpenButtonClick();

    Initialize();
  }

  void Update()
  {
    if ((IsGameClear || IsGameOver) && Input.GetMouseButtonDown(0))
    {
      Initialize();
    }
  }

  /// <summary>
  /// ゲームの初期化処理を行います
  /// </summary>
  public void Initialize()
  {
    gameClearText.SetActive(false);
    gameOverText.SetActive(false);
    cellPrefab.gameObject.SetActive(true);

    // 前のゲームのマスがある場合は削除
    foreach (var cell in cells)
    {
      Destroy(cell.gameObject);
    }
    cells.Clear();

    // セルを生成・配置
    var cellContainerRect = cellContainer.GetComponent<RectTransform>();
    cellContainer.cellSize = new Vector2(cellContainerRect.sizeDelta.x / size.x, cellContainerRect.sizeDelta.y / size.y);
    for (var x = 0; x < size.x; x++)
    {
      for (var y = 0; y < size.y; y++)
      {
        var cell = Instantiate(cellPrefab);
        cell.Initialize(this, x, y);
        cell.transform.SetParent(cellContainer.transform);
        cell.transform.localScale = Vector3.one;
        cells.Add(cell);
      }
    }
    // セルに地雷を配置
    for (var i = 0; i < size.x * size.y * 0.1; i++)
    {
     var noMineSells = cells.Where(x => !x.HasMine);
     noMineSells.ElementAt(UnityEngine.Random.Range(0, noMineSells.Count())).SetMine(true);
    }

    cellPrefab.gameObject.SetActive(false);
  }

  /// <summary>
  /// マスを開いた際の処理です
  /// </summary>
  /// <param name=“cell“>Cell.</param>
  public bool OnCellOpen(GameScene_Cell cell)
  {
    if (cell.HasMine)
    {
      GameOver();
      return false;
    }

    // 周りのマスに地雷が何個あるかチェック
    var aroundCells = GetAroundCells(cell);
    var mineCountOnAroundCell = aroundCells.Count(x => x.HasMine);
    cell.Number = mineCountOnAroundCell;
    if (mineCountOnAroundCell == 0)
    {
      // 周りに地雷が無い場合、周りのマスを連鎖的に開く
      foreach (var aroundCell in aroundCells)
      {
        if (!aroundCell.Open())
        {
          return false;
        }
      }
    }

    // 地雷が無いマスが全部開かれたならゲームクリア
    if (!cells.Any(x => !x.HasMine && !x.IsOpened))
    {
      GameClear();
      return false;
    }

    return true;
  }

  /// <summary>
  /// 指定マスの周囲のマスを取得します
  /// </summary>
  /// <returns>The around cells.</returns>
  /// <param name=“targetCell“>Target cell.</param>
  List<GameScene_Cell> GetAroundCells(GameScene_Cell targetCell)
  {
    var aroundCells = new List<GameScene_Cell>();
    Action<int, int> addCellIfExists = (x, y) =>
    {
      var cell = cells.FirstOrDefault(c => c.X == x && c.Y == y);
      if (null != cell)
      {
        aroundCells.Add(cell);
      }
    };
    addCellIfExists(targetCell.X - 1, targetCell.Y - 1);
    addCellIfExists(targetCell.X - 1, targetCell.Y);
    addCellIfExists(targetCell.X - 1, targetCell.Y + 1);
    addCellIfExists(targetCell.X, targetCell.Y - 1);
    addCellIfExists(targetCell.X, targetCell.Y + 1);
    addCellIfExists(targetCell.X + 1, targetCell.Y - 1);
    addCellIfExists(targetCell.X + 1, targetCell.Y);
    addCellIfExists(targetCell.X + 1, targetCell.Y + 1);
    return aroundCells;
  }

  /// <summary>
  /// ゲームクリア処理
  /// </summary>
  void GameClear()
  {
    gameClearText.SetActive(true);
  }

  /// <summary>
  /// ゲームオーバー処理
  /// </summary>
  void GameOver()
  {
    gameOverText.SetActive(true);
    foreach (var cell in cells.Where(x => x.HasMine))
    {
      cell.Reveal();
    }
  }

  /// <summary>
  /// OPENモードのボタン押下時の処理
  /// </summary>
  void OnSetOpenButtonClick()
  {
    mode = Modes.Open;
    setOpenButton.image.color = Color.red;
    setCheckButton.image.color = Color.white;
  }

  /// <summary>
  /// CHECKモードのボタン押下時の処理
  /// </summary>
  void OnSetCheckButtonClick()
  {
    mode = Modes.Check;
    setOpenButton.image.color = Color.white;
    setCheckButton.image.color = Color.red;
  }

  [Serializable]
  class Size
  {
    public int x = 10;
    public int y = 10;
  }
}

・GameScene_Cell.cs
マス目のクラスです。
今回はマス自体が自律的に行う動作が無いため入力&表示に特化しており、処理はほぼController側に任せています。

using UnityEngine;
using System.Collections;
using UnityEngine.UI;
using System.Linq;
using UnityEngine.EventSystems;

/// <summary>
/// セル
/// </summary>
[RequireComponent(typeof(Button))]
public class GameScene_Cell : UIBehaviour
{
  public int X { get { return x; } }

  public int Y { get { return y; } }

  public bool IsOpened { get { return !overlay.activeSelf; } }

  public bool IsChecked { get { return checkMark.activeSelf; } }

  public bool HasMine { get { return mine.activeSelf; } }

  public int Number { set { numberText.text = value == 0 ? ““: string.Format(“{0}“, value); } }

  [SerializeField]
  Text numberText;
  [SerializeField]
  GameObject mine;
  [SerializeField]
  GameObject overlay;
  [SerializeField]
  GameObject checkMark;

  GameScene_Controller controller;
  int x;
  int y;

  protected override void Start()
  {
    base.Start();
    GetComponent<Button>().onClick.AddListener(OnClick);
  }

  /// <summary>
  /// マスの初期化処理です
  /// </summary>
  /// <param name=“controller“>Controller.</param>
  /// <param name=“x“>The x coordinate.</param>
  /// <param name=“y“>The y coordinate.</param>
  public void Initialize(GameScene_Controller controller, int x, int y)
  {
    this.controller = controller;
    this.x = x;
    this.y = y;

    numberText.gameObject.SetActive(true);
    mine.SetActive(false);
    overlay.SetActive(true);
    checkMark.SetActive(false);
  }

  /// <summary>
  /// 地雷をセットします
  /// </summary>
  /// <param name=“hasMine“>If set to <c>true</c> has mine.</param>
  public void SetMine(bool hasMine)
  {
    mine.SetActive(hasMine);
  }

  /// <summary>
  /// マスのをリベールします(見た目のみの処理)
  /// </summary>
  public void Reveal()
  {
    overlay.SetActive(false);
  }

  /// <summary>
  /// マスを開きます
  /// </summary>
  public bool Open()
  {
    if (IsOpened || IsChecked)
    {
      return true;
    }
    Reveal();
    return controller.OnCellOpen(this);
  }

  /// <summary>
  /// マスがクリックされた際の処理
  /// </summary>
  void OnClick()
  {
    if (IsOpened)
    {
      // 既にオープンされたマスなら何もしない
      return;
    }
    switch (controller.Mode)
    {
      case GameScene_Controller.Modes.Open:
        Open();
        break;
      case GameScene_Controller.Modes.Check:
        checkMark.SetActive(!checkMark.activeSelf);
        break;
    }
  }
}

今回のアルゴリズムのポイントは、マスに座標を振って管理することによって、周りのマスの情報を得たり、周りのマスを連鎖的に開いたり、といったマス同士の連携を可能にしている点です。

周りのマスを連鎖的に開く処理自体は簡単ですが、「一度処理を実行したマスに対して、2回目以降の処理が実行されないよう制御しなければならない」点に注意してください。
(今回のプログラムですと、GameScene_Cell.Open()メソッドの先頭で行っている「IsOpenまたはIsChecked状態のマスは、Open()を中断する」制御です

この制御を忘れると、クリックしたマスでOpen()処理が実行され、その周りのマスもOpen()処理が実行され、それらのマスの周りのマス(クリックされたマスも含む)でOpen()処理が実行され・・・といった形でOpen()処理が無限ループし、ゲームが動かなくなります。

■遊んでみよう

今回のプロジェクトをアップしました。
こちらを使ってアルゴリズムの動きを確認してみましょう。
https://github.com/akako/gamealgorithm-mine-sweeper
なお、ドット絵は筆者がやっつけで描いたものですので2次利用・改変等ご自由にどうぞ。

[動画]
https://youtu.be/TO9kGrnWmyY

ちなみに、今回作ったマインスイーパではOPEN(マスを開くモード)とCHECK(マスにチェックを付けるモード)の切り替えボタンを設けています。
ただ、PCやMacでプレイするのであれば、左クリックでマスを開き、右クリックでチェックを付けられた方が操作しやすいかと思いますので、このあたりは好みによって工夫してみてください。
(Unity UIのButtonは右クリックに対応していないので、実装がちょいと面倒ですが・・・

■まとめ

300行程度のプログラムで、誰もが知っているゲームが再現できてしまう。
ゲームのアルゴリズムの魅力、ご理解いただけましたでしょうか。

また、今回はマインスイーパのアルゴリズムを作りましたが、周りのマスとの連携方法に少し手を加えることで、マス目を使った様々なゲームが作れます。

ナンプレなども考え方はほとんど同じですので、様々なゲームの開発にチャレンジしてみるのも面白いですよ。

■次回予告

次回はマス目を使ったゲームの続編として、リバーシのアルゴリズムを作ってみようと思います。
ついでに対戦相手のAIも作っちゃいますよ!

原稿:賀好 昭仁
qnoteスマホアプリ開発チーム技術主任。PHP・Android・iOS・Unityなど複数のプラットフォームでの開発を行う。
しばしば7匹の先輩猫社員たちにイスを占領される。

この記事はどうでしたか?

おすすめの記事

キャリアを考える

BACK TO TOP ∧

FOLLOW