マインスイーパー


MonoGameでド定番ゲーム、マインスイーパーを作ってみましょう。

このゲーム、以前はWindowsに最初から搭載されていたため、知らない者はいないというほどの有名ゲームでしたが、最近のWindowsには入っていないため、若い人だと知らない人も多いかもしれません。

ルールは以下の通りです。

  • マスが並んでいる
  • 何箇所かには地雷が埋まっている。どこが地雷かはプレイヤーにはわからない。
  • プレイヤーはクリックしてマスを開ける(掘る)
  • 地雷の場所を掘ってしまうとゲームオーバー
  • 地雷ではない場所を掘ると、そのマスの周囲に地雷が何個あるかが表示される。例えば、0と表示された場合、そのマスの周囲8マスの中には地雷が無いことを表す。2と表示された場合は、そのマスの周囲8マスの中に地雷が2個存在することを表す。
  • 地雷以外の場所を全て掘ることができれば、ゲームクリア!
  • なお、周囲の情報から、確実に地雷が存在すると推測されるマスには目印として旗を立てることができる(右クリック)


簡単なルールで、適度なやりごたえがあり、謎の中毒性があるゲームです。

画像リソースのダウンロード

ここから画像リソースをダウンロードし、zipファイルを解凍しておいてください。
https://github.com/ymotoyama/Samples/raw/master/MineSweeper%20Resources.zip


新規プロジェクト作成

Visual Studioから新規プロジェクト(MonoGame Windows Project)を作成します。
本記事のサンプルコードでは、プロジェクト名を「MineSweeper」にしました。

プロジェクトを作成したら、ダウンロードした画像をMonoGamePipeline Toolで登録してビルドしておいてください。

定数の定義

ゲームに必要な各種定数を定義しておきます。
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;

namespace MineSweeper
{
    public class Game1 : Game
    {
        static readonly int WindowWidth = 320; // ウィンドウの幅
        static readonly int WindowHeight = 320; // ウィンドウの高さ

        static readonly int BoardWidth = 10; // マスの数(横)
        static readonly int BoardHeight = 10; // マスの数(縦)
        static readonly int GridSize = 16; // 1マスの大きさ(ピクセル数)
        static readonly int TotalMineNum = 12; // 地雷の総数

        // 盤面の位置(左上の座標)
        static readonly Vector2 BoardOrigin = new Vector2(80, 80);

        GraphicsDeviceManager graphics;
        SpriteBatch spriteBatch;

        public Game1()
        {
            graphics = new GraphicsDeviceManager(this);
            Content.RootDirectory = "Content";
        }
BoardWidthとBoardHeightは盤面が何×何マスなのかを表す定数です。後で、お好みで変えて大丈夫です。
GridSizeは、1マスの大きさ(ピクセル数)です。今回用意した画像が16×16ピクセルなので、16としています。
TotalMineNumは地中に埋まった地雷の総数です。これも好みで変えて大丈夫です。

マウスを表示させる

デフォルトだとマウスカーソルが非表示なので、表示させましょう。
        public Game1()
        {
            graphics = new GraphicsDeviceManager(this);
            Content.RootDirectory = "Content";

            // マウスを表示させる
            IsMouseVisible = true;
        }

ウィンドウサイズ

ウィンドウの大きさを変えてみましょう。
        public Game1()
        {
            graphics = new GraphicsDeviceManager(this);
            Content.RootDirectory = "Content";

            // マウスを表示させる
            IsMouseVisible = true;

            // ウィンドウサイズ変更
            graphics.PreferredBackBufferWidth = WindowWidth;
            graphics.PreferredBackBufferHeight = WindowHeight;
            graphics.ApplyChanges();
        }
定数WindowWidthとWindowsHeight の値は好きなように変えて構いません。

画像の読み込み

使う画像を読み込む処理を書いておきましょう。
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;

namespace MineSweeper
{
    public class Game1 : Game
    {
        static readonly int WindowWidth = 320; // ウィンドウの幅
        static readonly int WindowHeight = 320; // ウィンドウの高さ

        static readonly int BoardWidth = 10; // マスの数(横)
        static readonly int BoardHeight = 10; // マスの数(縦)
        static readonly int GridSize = 16; // 1マスの大きさ(ピクセル数)
        static readonly int TotalMineNum = 12; // 地雷の総数

        // 盤面の位置(左上の座標)
        static readonly Vector2 BoardOrigin = new Vector2(80, 80);

        Texture2D textureBlock; // ブロックの画像
        Texture2D textureGrid; // マス目の画像
        Texture2D textureFlag; // 旗の画像
        Texture2D textureMine; // 地雷の画像
        Texture2D textureFaceNormal; // 顔(普通)
        Texture2D textureFaceSmile; // 顔(笑顔)
        Texture2D textureFaceDying; // 顔(ゲッソリ)
        Texture2D[] textureNum = new Texture2D[10]; // 0~9までの画像

        GraphicsDeviceManager graphics;
        SpriteBatch spriteBatch;

        public Game1()
        {
            graphics = new GraphicsDeviceManager(this);
            Content.RootDirectory = "Content";

            // マウスを表示させる
            IsMouseVisible = true;

            // ウィンドウサイズ変更
            graphics.PreferredBackBufferWidth = WindowWidth;
            graphics.PreferredBackBufferHeight = WindowHeight;
            graphics.ApplyChanges();
        }

        protected override void Initialize()
        {
            base.Initialize();
        }

        protected override void LoadContent()
        {
            spriteBatch = new SpriteBatch(GraphicsDevice);
         
            // 画像の読み込み処理
            textureBlock = Content.Load<Texture2D>("block");
            textureGrid = Content.Load<Texture2D>("grid");
            textureFlag = Content.Load<Texture2D>("flag");
            textureMine = Content.Load<Texture2D>("mine");
            textureFaceNormal = Content.Load<Texture2D>("face_normal");
            textureFaceSmile = Content.Load<Texture2D>("face_smile");
            textureFaceDying = Content.Load<Texture2D>("face_dying");

            for (int i = 0; i < 10; i++)
            {
                textureNum[i] = Content.Load<Texture2D>("num_" + i);
            }
        }

        protected override void UnloadContent()
        {
        }

        protected override void Update(GameTime gameTime)
        {
            if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape))
                Exit();
            


            base.Update(gameTime);
        }

        protected override void Draw(GameTime gameTime)
        {
            GraphicsDevice.Clear(Color.CornflowerBlue);



            base.Draw(gameTime);
        }
    }
}
今回は下記のような0~9までの数字の画像を扱うのですが、ここでひと工夫しています。

配列textureNumの0番目に0の画像、1番目に1の画像…というふうに格納していきます。すると、使いたい時に、簡単に取り出せるというわけです。大量の画像を扱うときは、このように何らかの工夫をする必要があります。さもないと、大量のif文を書くハメになります。

Blockクラスの作成

いよいよゲームのロジックを作っていきます。
今回は、Blockというクラスを作り、1マス分の情報や描画処理は全てそこにまとめようと思います。
そして、プログラム全体では、そのBlockがマス目の数だけ存在する…という感じです。
それぞれのマスにはどのような情報が必要でしょうか?

  • 地雷が埋まっているか
  • 周囲の地雷の数
  • 掘ったかどうか
  • 旗を立てたかどうか
まあ、こんなところでしょう。
では、プロジェクトに新規クラスBlockを追加してください。
using Microsoft.Xna.Framework; // Vector2などのために必要
using Microsoft.Xna.Framework.Graphics; // SpriteBatchなどのために必要

namespace MineSweeper
{
    // 1マスを表すクラス。
    class Block
    {
        // このマスは地雷か?
        public bool IsMine = false;

        // 周囲の地雷の個数
        public int NeighborMineCount = 0;

        // 旗が立っているか
        public bool Flag = false;

        // 開けられたか
        public bool Opened = false;

        Texture2D textureBlock; // ブロックの画像
        Texture2D textureGrid; // マス目の画像
        Texture2D textureFlag; // 旗の画像
        Texture2D textureMine; // 地雷の画像
        Texture2D[] textureNum; // 0~9までの画像

        // マスの位置(スクリーン座標)
        Vector2 position;

        // コンストラクタ
        // このクラス内でテクスチャを描画したいので、
        // テクスチャの参照をコンストラクタでもらっておく。
        public Block(Vector2 pos, Texture2D block, Texture2D grid, Texture2D flag, Texture2D mine, Texture2D[] numbers)
        {
            position = pos;
            textureBlock = block;
            textureGrid = grid;
            textureFlag = flag;
            textureMine = mine;
            textureNum = numbers;
        }
     
        // 描画処理
        public void Draw(SpriteBatch spriteBatch)
        {
            // とりあえず常にマス目を描画。あとでちゃんと作る
            spriteBatch.Draw(textureGrid, position, Color.White);
        }
    }
}
自分のことは自分でやるのがオブジェクト指向プログラミングの基本。マスの描画はBlockクラスにさせたい。そのために、描画に必要な各種Texture2Dをコンストラクタで渡してもらって、自分で持っておくようにします。
描画処理はまだ仮のものです。後で作り込みます。

publicな変数でIsMine, NeighborMineCount, Flag, Openedなどがあります。外部から頻繁に参照したいので、publicにしています。ここは本来は、C#であれば、プロパティという機能を使うところですが、話がそれるので、やめます。C#をマスターしたい人はプロパティについて勉強してみてください。

並行して、ゲームのメインロジックのほうも作っていきます。
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using System; // Randomのために必要

namespace MineSweeper
{
    public class Game1 : Game
    {
        static readonly int WindowWidth = 320; // ウィンドウの幅
        static readonly int WindowHeight = 320; // ウィンドウの高さ

        static readonly int BoardWidth = 10; // マスの数(横)
        static readonly int BoardHeight = 10; // マスの数(縦)
        static readonly int GridSize = 16; // 1マスの大きさ(ピクセル数)
        static readonly int TotalMineNum = 12; // 地雷の総数

        // 盤面の位置(左上の座標)
        static readonly Vector2 BoardOrigin = new Vector2(80, 80);

        Texture2D textureBlock; // ブロックの画像
        Texture2D textureGrid; // マス目の画像
        Texture2D textureFlag; // 旗の画像
        Texture2D textureMine; // 地雷の画像
        Texture2D textureFaceNormal; // 顔(普通)
        Texture2D textureFaceSmile; // 顔(笑顔)
        Texture2D textureFaceDying; // 顔(ゲッソリ)
        Texture2D[] textureNum = new Texture2D[10]; // 0~9までの画像

        // 盤面の情報を格納するための二次元配列
        Block[,] board = new Block[BoardWidth, BoardHeight];

        GraphicsDeviceManager graphics;
        SpriteBatch spriteBatch;

        public Game1()
        {
            graphics = new GraphicsDeviceManager(this);
            Content.RootDirectory = "Content";

            // マウスを表示させる
            IsMouseVisible = true;

            // ウィンドウサイズ変更
            graphics.PreferredBackBufferWidth = WindowWidth;
            graphics.PreferredBackBufferHeight = WindowHeight;
            graphics.ApplyChanges();
        }

        protected override void Initialize()
        {
            base.Initialize();

            // 盤面の初期化
            InitBoard();
        }

        protected override void LoadContent()
        {
            spriteBatch = new SpriteBatch(GraphicsDevice);
            
            // 画像の読み込み処理
            textureBlock = Content.Load<Texture2D>("block");
            textureGrid = Content.Load<Texture2D>("grid");
            textureFlag = Content.Load<Texture2D>("flag");
            textureMine = Content.Load<Texture2D>("mine");
            textureFaceNormal = Content.Load<Texture2D>("face_normal");
            textureFaceSmile = Content.Load<Texture2D>("face_smile");
            textureFaceDying = Content.Load<Texture2D>("face_dying");

            for (int i = 0; i < 10; i++)
            {
                textureNum[i] = Content.Load<Texture2D>("num_" + i);
            }
        }

        protected override void UnloadContent()
        {
        }

        protected override void Update(GameTime gameTime)
        {
            if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape))
                Exit();
            


            base.Update(gameTime);
        }

        // 地中の情報を初期化する
        void InitBoard()
        {
            // 二次元配列の全要素にBlockインスタンスを詰める
            for (int y = 0; y < BoardHeight; y++)
            {
                for (int x = 0; x < BoardWidth; x++)
                {
                    // マスのスクリーン座標を計算
                    Vector2 position = new Vector2(GridSize * x, GridSize * y) + BoardOrigin;

                    // Blockのインスタンスを生成して二次元配列に格納する
                    board[x, y] = new Block(position, textureBlock, textureGrid, textureFlag, textureMine, textureNum);
                }
            }
        }

        protected override void Draw(GameTime gameTime)
        {
            GraphicsDevice.Clear(Color.CornflowerBlue);

            // SpriteBatchを使うために必要
            spriteBatch.Begin();

            // 盤面の描画
            for (int y = 0; y < BoardHeight; y++)
            {
                for (int x = 0; x < BoardWidth; x++)
                {
                    board[x, y].Draw(spriteBatch);
                }
            }

            // SpriteBatchを使うために必要
            spriteBatch.End();

            base.Draw(gameTime);
        }
    }
}
ゲーム中の全てのマスを保持するために、Blockの二次元配列を用意しました。
描画処理は、二次元配列に格納されている全てのBlockのDrawメソッドを呼び出すだけです。

地雷を埋める

上記では、まだ地雷が埋まっていません。ランダムな位置に地雷を埋めていきます。
また、地雷ではない場所については、周囲の地雷の数を数え、BlockのNeighborMineCountに格納していきます。なお、Neighbor(ネイバー)とは「隣接する」という意味です。
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using System;

namespace MineSweeper
{
    public class Game1 : Game
    {
        static readonly int WindowWidth = 320; // ウィンドウの幅
        static readonly int WindowHeight = 320; // ウィンドウの高さ

        static readonly int BoardWidth = 10; // マスの数(横)
        static readonly int BoardHeight = 10; // マスの数(縦)
        static readonly int GridSize = 16; // 1マスの大きさ(ピクセル数)
        static readonly int TotalMineNum = 12; // 地雷の総数

        // 盤面の位置(左上の座標)
        static readonly Vector2 BoardOrigin = new Vector2(80, 80);

        Texture2D textureBlock; // ブロックの画像
        Texture2D textureGrid; // マス目の画像
        Texture2D textureFlag; // 旗の画像
        Texture2D textureMine; // 地雷の画像
        Texture2D textureFaceNormal; // 顔(普通)
        Texture2D textureFaceSmile; // 顔(笑顔)
        Texture2D textureFaceDying; // 顔(ゲッソリ)
        Texture2D[] textureNum = new Texture2D[10]; // 0~9までの画像

        // 盤面の情報を格納するための二次元配列
        Block[,] board = new Block[BoardWidth, BoardHeight];

        GraphicsDeviceManager graphics;
        SpriteBatch spriteBatch;

        public Game1()
        {
            graphics = new GraphicsDeviceManager(this);
            Content.RootDirectory = "Content";

            // マウスを表示させる
            IsMouseVisible = true;

            // ウィンドウサイズ変更
            graphics.PreferredBackBufferWidth = WindowWidth;
            graphics.PreferredBackBufferHeight = WindowHeight;
            graphics.ApplyChanges();
        }

        protected override void Initialize()
        {
            base.Initialize();

            // 盤面の初期化
            InitBoard();
        }

        protected override void LoadContent()
        {
            spriteBatch = new SpriteBatch(GraphicsDevice);
            
            // 画像の読み込み処理
            textureBlock = Content.Load<Texture2D>("block");
            textureGrid = Content.Load<Texture2D>("grid");
            textureFlag = Content.Load<Texture2D>("flag");
            textureMine = Content.Load<Texture2D>("mine");
            textureFaceNormal = Content.Load<Texture2D>("face_normal");
            textureFaceSmile = Content.Load<Texture2D>("face_smile");
            textureFaceDying = Content.Load<Texture2D>("face_dying");

            for (int i = 0; i < 10; i++)
            {
                textureNum[i] = Content.Load<Texture2D>("num_" + i);
            }
        }

        protected override void UnloadContent()
        {
        }

        protected override void Update(GameTime gameTime)
        {
            if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape))
                Exit();
            


            base.Update(gameTime);
        }

        // 地中の情報を初期化する
        void InitBoard()
        {
            // 二次元配列の全要素にBlockインスタンスを詰める
            for (int y = 0; y < BoardHeight; y++)
            {
                for (int x = 0; x < BoardWidth; x++)
                {
                    // マスのスクリーン座標を計算
                    Vector2 position = new Vector2(GridSize * x, GridSize * y) + BoardOrigin;

                    // Blockのインスタンスを生成して二次元配列に格納する
                    board[x, y] = new Block(position, textureBlock, textureGrid, textureFlag, textureMine, textureNum);
                }
            }

            // ランダムな場所に地雷を埋める
            Random rand = new Random();
            int mineNum = 0;
            while (mineNum < TotalMineNum)
            {
                int x = rand.Next(0, BoardWidth);
                int y = rand.Next(0, BoardHeight);

                // ランダムに選んだ場所にまだ地雷が無ければ、そこに地雷を埋める
                if (!board[x, y].IsMine)
                {
                    board[x, y].IsMine = true;
                    mineNum++;
                }
            }

            // 全てのマスについて、周囲の地雷の数を数え、格納する
            for (int y = 0; y < BoardHeight; y++)
            {
                for (int x = 0; x < BoardWidth; x++)
                {
                    // そのマスが地雷の場合は、何もしない
                    if (board[x, y].IsMine)
                        continue;

                    // 地雷でないマスの場合、周囲の地雷の数を数え、格納する
                    board[x, y].NeighborMineCount = CountNeighborMines(x, y);
                }
            }
        }

        // 指定されたマスの周囲に地雷は何個あるか数える
        int CountNeighborMines(int x, int y)
        {
            int mineNum = 0;

            if (IsMine(x - 1, y - 1)) mineNum++; // 左上
            if (IsMine(x + 0, y - 1)) mineNum++; // 上
            if (IsMine(x + 1, y - 1)) mineNum++; // 右上
            if (IsMine(x - 1, y + 0)) mineNum++; // 左
            if (IsMine(x + 1, y + 0)) mineNum++; // 右
            if (IsMine(x - 1, y + 1)) mineNum++; // 左下
            if (IsMine(x + 0, y + 1)) mineNum++; // 下
            if (IsMine(x + 1, y + 1)) mineNum++; // 右下

            return mineNum;
        }

        // 指定されたマスが地雷か?
        bool IsMine(int x, int y)
        {
            return IsInsideBoard(x, y) && board[x, y].IsMine;
        }

        // 指定されたマスが盤面の内側か?
        bool IsInsideBoard(int x, int y)
        {
            return x >= 0 && x < BoardWidth && y >= 0 && y < BoardHeight;
        }

        protected override void Draw(GameTime gameTime)
        {
            GraphicsDevice.Clear(Color.CornflowerBlue);

            // SpriteBatchを使うために必要
            spriteBatch.Begin();

            // 盤面の描画
            for (int y = 0; y < BoardHeight; y++)
            {
                for (int x = 0; x < BoardWidth; x++)
                {
                    board[x, y].Draw(spriteBatch);
                }
            }

            // SpriteBatchを使うために必要
            spriteBatch.End();

            base.Draw(gameTime);
        }
    }
}
今回は、ランダムな場所に地雷を埋めるために、以下の方法をとっています。

  1. ランダムにマスを選ぶ
  2. そこが地雷でなければ、地雷を埋める。既に地雷が埋まっている場合は1の場所の選択からやりなおし
  3. 地雷を埋めたい数だけ、上記(1と2)を繰り返す
 他のやり方としては、配列の先頭から順に、埋めたい数だけ地雷を格納していき、最後に配列全体をシャッフルするという手段もあります。どちらでも構いません。

CountNeighborMinesというメソッドでは、指定されたマスの周囲8マスに、地雷が何個あるかを数えています。単純な処理ですが、配列の外側を参照しないようにすることだけは注意が必要です。

正しく地雷が埋まったか、また、正しく周囲の地雷の数が数えられているかを確認するために、Blockの描画処理を改造します。
using Microsoft.Xna.Framework; // Vector2などのために必要
using Microsoft.Xna.Framework.Graphics; // SpriteBatchなどのために必要

namespace MineSweeper
{
    // 1マスを表すクラス。
    class Block
    {
        // このマスは地雷か?
        public bool IsMine = false;

        // 周囲の地雷の個数
        public int NeighborMineCount = 0;

        // 旗が立っているか
        public bool Flag = false;

        // 開けられたか
        public bool Opened = false;

        Texture2D textureBlock; // ブロックの画像
        Texture2D textureGrid; // マス目の画像
        Texture2D textureFlag; // 旗の画像
        Texture2D textureMine; // 地雷の画像
        Texture2D[] textureNum; // 0~9までの画像

        // マスの位置(スクリーン座標)
        Vector2 position;

        // コンストラクタ
        // このクラス内でテクスチャを描画したいので、
        // テクスチャの参照をコンストラクタでもらっておく。
        public Block(Vector2 pos, Texture2D block, Texture2D grid, Texture2D flag, Texture2D mine, Texture2D[] numbers)
        {
            position = pos;
            textureBlock = block;
            textureGrid = grid;
            textureFlag = flag;
            textureMine = mine;
            textureNum = numbers;
        }
     
        // 描画処理
        public void Draw(SpriteBatch spriteBatch)
        {
            // マス目を描画
            spriteBatch.Draw(textureGrid, position, Color.White);

            // このマスが地雷であれば、地雷を描画
            if (IsMine)
            {
                spriteBatch.Draw(textureMine, position, Color.White);
            }
            // 地雷でなければ、周囲の地雷の数を描画
            else
            {
                spriteBatch.Draw(textureNum[NeighborMineCount], position, Color.White);
            }
        }
    }
}
 
以下のことを確認してください。

  • 実行するたびにランダムな位置に地雷があること
  • 数字マス(周囲の地雷の数)が正しいこと

クリック時の処理

左クリックでマスを掘る、右クリックで旗を立てるという機能を作っていきます。
まずはBlockクラスから改造していきます。
using Microsoft.Xna.Framework; // Vector2などのために必要
using Microsoft.Xna.Framework.Graphics; // SpriteBatchなどのために必要

namespace MineSweeper
{
    // 1マスを表すクラス。
    class Block
    {
        // このマスは地雷か?
        public bool IsMine = false;

        // 周囲の地雷の個数
        public int NeighborMineCount = 0;

        // 旗が立っているか
        public bool Flag = false;

        // 開けられたか
        public bool Opened = false;

        Texture2D textureBlock; // ブロックの画像
        Texture2D textureGrid; // マス目の画像
        Texture2D textureFlag; // 旗の画像
        Texture2D textureMine; // 地雷の画像
        Texture2D[] textureNum; // 0~9までの画像

        // マスの位置(スクリーン座標)
        Vector2 position;

        // コンストラクタ
        // このクラス内でテクスチャを描画したいので、
        // テクスチャの参照をコンストラクタでもらっておく。
        public Block(Vector2 pos, Texture2D block, Texture2D grid, Texture2D flag, Texture2D mine, Texture2D[] numbers)
        {
            position = pos;
            textureBlock = block;
            textureGrid = grid;
            textureFlag = flag;
            textureMine = mine;
            textureNum = numbers;
        }

        // 左クリックされた時の処理
        public void OnLeftClick()
        {
            // もう既に開いている場合は何もしない
            if (Opened)
                return;

            // 開く
            Opened = true;
        }

        // 右クリックされた時の処理
        public void OnRightClick()
        {
            // 既に開いてる場合、右クリックされても、何もしない
            if (Opened)
                return;

            // 旗のON/OFFを切り替える
            Flag = !Flag;
        }

        // 描画処理
        public void Draw(SpriteBatch spriteBatch)
        {
            // 開いてる
            if (Opened)
            {
                // マス目を描画
                spriteBatch.Draw(textureGrid, position, Color.White);

                // このマスが地雷であれば、地雷を描画
                if (IsMine)
                {
                    spriteBatch.Draw(textureMine, position, Color.White);
                }
                // 地雷でなければ、周囲の地雷の数を描画
                else
                {
                    spriteBatch.Draw(textureNum[NeighborMineCount], position, Color.White);
                }
            }
            // 開いてない
            else
            {
                // ブロックを描画
                spriteBatch.Draw(textureBlock, position, Color.White);

                // 旗が立ててあれば、旗を描画
                if (Flag)
                {
                    spriteBatch.Draw(textureFlag, position, Color.White);
                }
            }
        }
    }
}
続いて、Gameクラスのほうを改造します。
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using System;

namespace MineSweeper
{
    public class Game1 : Game
    {
        static readonly int WindowWidth = 320; // ウィンドウの幅
        static readonly int WindowHeight = 320; // ウィンドウの高さ

        static readonly int BoardWidth = 10; // マスの数(横)
        static readonly int BoardHeight = 10; // マスの数(縦)
        static readonly int GridSize = 16; // 1マスの大きさ(ピクセル数)
        static readonly int TotalMineNum = 12; // 地雷の総数

        // 盤面の位置(左上の座標)
        static readonly Vector2 BoardOrigin = new Vector2(80, 80);

        Texture2D textureBlock; // ブロックの画像
        Texture2D textureGrid; // マス目の画像
        Texture2D textureFlag; // 旗の画像
        Texture2D textureMine; // 地雷の画像
        Texture2D textureFaceNormal; // 顔(普通)
        Texture2D textureFaceSmile; // 顔(笑顔)
        Texture2D textureFaceDying; // 顔(ゲッソリ)
        Texture2D[] textureNum = new Texture2D[10]; // 0~9までの画像

        // 盤面の情報を格納するための二次元配列
        Block[,] board = new Block[BoardWidth, BoardHeight];

        // 1フレーム前のマウスの状態
        MouseState prevMouse;
     
        GraphicsDeviceManager graphics;
        SpriteBatch spriteBatch;

        public Game1()
        {
            graphics = new GraphicsDeviceManager(this);
            Content.RootDirectory = "Content";

            // マウスを表示させる
            IsMouseVisible = true;

            // ウィンドウサイズ変更
            graphics.PreferredBackBufferWidth = WindowWidth;
            graphics.PreferredBackBufferHeight = WindowHeight;
            graphics.ApplyChanges();
        }

        protected override void Initialize()
        {
            base.Initialize();

            // 盤面の初期化
            InitBoard();
        }

        protected override void LoadContent()
        {
            spriteBatch = new SpriteBatch(GraphicsDevice);
            
            // 画像の読み込み処理
            textureBlock = Content.Load<Texture2D>("block");
            textureGrid = Content.Load<Texture2D>("grid");
            textureFlag = Content.Load<Texture2D>("flag");
            textureMine = Content.Load<Texture2D>("mine");
            textureFaceNormal = Content.Load<Texture2D>("face_normal");
            textureFaceSmile = Content.Load<Texture2D>("face_smile");
            textureFaceDying = Content.Load<Texture2D>("face_dying");

            for (int i = 0; i < 10; i++)
            {
                textureNum[i] = Content.Load<Texture2D>("num_" + i);
            }
        }

        protected override void UnloadContent()
        {
        }

        protected override void Update(GameTime gameTime)
        {
            if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape))
                Exit();

            // マウスの状態を取得
            MouseState mouse = Mouse.GetState();

            // マウスカーソルの座標をマス番号に変換する
            int x = (mouse.X - (int)BoardOrigin.X) / GridSize;
            int y = (mouse.Y - (int)BoardOrigin.Y) / GridSize;
         
            // 左クリックされたか?
            if (mouse.LeftButton == ButtonState.Released && prevMouse.LeftButton == ButtonState.Pressed && IsInsideBoard(x, y))
            {
                board[x, y].OnLeftClick();
            }
            // 右クリックされたか?
            else if (mouse.RightButton == ButtonState.Released && prevMouse.RightButton == ButtonState.Pressed && IsInsideBoard(x, y))
            {
                board[x, y].OnRightClick();
            }

            // 次のフレームで使うので、マウスの状態を保持しておく
            prevMouse = mouse;

            base.Update(gameTime);
        }

        // 地中の情報を初期化する
        void InitBoard()
        {
            // 二次元配列の全要素にBlockインスタンスを詰める
            for (int y = 0; y < BoardHeight; y++)
            {
                for (int x = 0; x < BoardWidth; x++)
                {
                    // マスのスクリーン座標を計算
                    Vector2 position = new Vector2(GridSize * x, GridSize * y) + BoardOrigin;

                    // Blockのインスタンスを生成して二次元配列に格納する
                    board[x, y] = new Block(position, textureBlock, textureGrid, textureFlag, textureMine, textureNum);
                }
            }

            // ランダムな場所に地雷を埋める
            Random rand = new Random();
            int mineNum = 0;
            while (mineNum < TotalMineNum)
            {
                int x = rand.Next(0, BoardWidth);
                int y = rand.Next(0, BoardHeight);

                // 適当に選んだ場所にまだ地雷が無ければ、そこに地雷を埋める
                if (!board[x, y].IsMine)
                {
                    board[x, y].IsMine = true;
                    mineNum++;
                }
            }

            // 全てのマスについて、周囲の地雷の数を数え、格納する
            for (int y = 0; y < BoardHeight; y++)
            {
                for (int x = 0; x < BoardWidth; x++)
                {
                    // そのマスが地雷の場合は、何もしない
                    if (board[x, y].IsMine)
                        continue;

                    // 地雷でないマスの場合、周囲の地雷の数を数え、格納する
                    board[x, y].NeighborMineCount = CountNeighborMines(x, y);
                }
            }
        }

        // 指定されたマスの周囲に地雷は何個あるか数える
        int CountNeighborMines(int x, int y)
        {
            int mineNum = 0;

            if (IsMine(x - 1, y - 1)) mineNum++; // 左上
            if (IsMine(x + 0, y - 1)) mineNum++; // 上
            if (IsMine(x + 1, y - 1)) mineNum++; // 右上
            if (IsMine(x - 1, y + 0)) mineNum++; // 左
            if (IsMine(x + 1, y + 0)) mineNum++; // 右
            if (IsMine(x - 1, y + 1)) mineNum++; // 左下
            if (IsMine(x + 0, y + 1)) mineNum++; // 下
            if (IsMine(x + 1, y + 1)) mineNum++; // 右下

            return mineNum;
        }

        // 指定されたマスが地雷か?
        bool IsMine(int x, int y)
        {
            return IsInsideBoard(x, y) && board[x, y].IsMine;
        }

        // 指定されたマスが盤面の内側か?
        bool IsInsideBoard(int x, int y)
        {
            return x >= 0 && x < BoardWidth && y >= 0 && y < BoardHeight;
        }

        protected override void Draw(GameTime gameTime)
        {
            GraphicsDevice.Clear(Color.CornflowerBlue);

            // SpriteBatchを使うために必要
            spriteBatch.Begin();

            // 盤面の描画
            for (int y = 0; y < BoardHeight; y++)
            {
                for (int x = 0; x < BoardWidth; x++)
                {
                    board[x, y].Draw(spriteBatch);
                }
            }

            // SpriteBatchを使うために必要
            spriteBatch.End();

            base.Draw(gameTime);
        }
    }
}

以下のことを確認してください。
  • 左クリックで掘れる
  • 既に掘ったところを左クリックしても何も起きない
  • 右クリックで旗を立てられる
  • 旗を右クリックすると、旗が消える
  • 既に掘ったところには旗は立てられない
  • 旗が立っていても掘れる

クリア・ゲームオーバーの判定

あとはクリアやゲームオーバーの判定処理を追加すれば、ゲーム完成です!
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using System;

namespace MineSweeper
{
    public class Game1 : Game
    {
        // ゲームの状態種別
        enum GameState
        {
            Playing, // プレイ中
            Cleared, // クリア
            Failed, // 失敗
        }

        static readonly int WindowWidth = 320; // ウィンドウの幅
        static readonly int WindowHeight = 320; // ウィンドウの高さ

        static readonly int BoardWidth = 10; // マスの数(横)
        static readonly int BoardHeight = 10; // マスの数(縦)
        static readonly int GridSize = 16; // 1マスの大きさ(ピクセル数)
        static readonly int TotalMineNum = 12; // 地雷の総数

        // 盤面の位置(左上の座標)
        static readonly Vector2 BoardOrigin = new Vector2(80, 80);

        Texture2D textureBlock; // ブロックの画像
        Texture2D textureGrid; // マス目の画像
        Texture2D textureFlag; // 旗の画像
        Texture2D textureMine; // 地雷の画像
        Texture2D textureFaceNormal; // 顔(普通)
        Texture2D textureFaceSmile; // 顔(笑顔)
        Texture2D textureFaceDying; // 顔(ゲッソリ)
        Texture2D[] textureNum = new Texture2D[10]; // 0~9までの画像

        // 盤面の情報を格納するための二次元配列
        Block[,] board = new Block[BoardWidth, BoardHeight];

        // 1フレーム前のマウスの状態
        MouseState prevMouse;

        // 現在のゲームの状態
        GameState state = GameState.Playing;

        GraphicsDeviceManager graphics;
        SpriteBatch spriteBatch;

        public Game1()
        {
            graphics = new GraphicsDeviceManager(this);
            Content.RootDirectory = "Content";

            // マウスを表示させる
            IsMouseVisible = true;

            // ウィンドウサイズ変更
            graphics.PreferredBackBufferWidth = WindowWidth;
            graphics.PreferredBackBufferHeight = WindowHeight;
            graphics.ApplyChanges();
        }

        protected override void Initialize()
        {
            base.Initialize();

            // 盤面の初期化
            InitBoard();
        }

        protected override void LoadContent()
        {
            spriteBatch = new SpriteBatch(GraphicsDevice);
            
            // 画像の読み込み処理
            textureBlock = Content.Load<Texture2D>("block");
            textureGrid = Content.Load<Texture2D>("grid");
            textureFlag = Content.Load<Texture2D>("flag");
            textureMine = Content.Load<Texture2D>("mine");
            textureFaceNormal = Content.Load<Texture2D>("face_normal");
            textureFaceSmile = Content.Load<Texture2D>("face_smile");
            textureFaceDying = Content.Load<Texture2D>("face_dying");

            for (int i = 0; i < 10; i++)
            {
                textureNum[i] = Content.Load<Texture2D>("num_" + i);
            }
        }

        protected override void UnloadContent()
        {
        }

        protected override void Update(GameTime gameTime)
        {
            if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape))
                Exit();

            // マウスの状態を取得
            MouseState mouse = Mouse.GetState();

            // マウスカーソルの座標をマス番号に変換する
            int x = (mouse.X - (int)BoardOrigin.X) / GridSize;
            int y = (mouse.Y - (int)BoardOrigin.Y) / GridSize;


            if (state == GameState.Playing)
            {
                // 左クリックされたか?
                if (mouse.LeftButton == ButtonState.Released && prevMouse.LeftButton == ButtonState.Pressed && IsInsideBoard(x, y))
                {
                    board[x, y].OnLeftClick();

                    // クリックした場所が地雷ならゲームオーバー
                    if (board[x, y].IsMine)
                    {
                        state = GameState.Failed;
                    }
                    else if (IsCleared())
                    {
                        state = GameState.Cleared;
                    }
                }
                // 右クリックされたか?
                else if (mouse.RightButton == ButtonState.Released && prevMouse.RightButton == ButtonState.Pressed && IsInsideBoard(x, y))
                {
                    board[x, y].OnRightClick();
                }
            }

            // 次のフレームで使うので、マウスの状態を保持しておく
            prevMouse = mouse;

            base.Update(gameTime);
        }

        // 地中の情報を初期化する
        void InitBoard()
        {
            // 二次元配列の全要素にBlockインスタンスを詰める
            for (int y = 0; y < BoardHeight; y++)
            {
                for (int x = 0; x < BoardWidth; x++)
                {
                    // マスのスクリーン座標を計算
                    Vector2 position = new Vector2(GridSize * x, GridSize * y) + BoardOrigin;

                    // Blockのインスタンスを生成して二次元配列に格納する
                    board[x, y] = new Block(position, textureBlock, textureGrid, textureFlag, textureMine, textureNum);
                }
            }

            // ランダムな場所に地雷を埋める
            Random rand = new Random();
            int mineNum = 0;
            while (mineNum < TotalMineNum)
            {
                int x = rand.Next(0, BoardWidth);
                int y = rand.Next(0, BoardHeight);

                // 適当に選んだ場所にまだ地雷が無ければ、そこに地雷を埋める
                if (!board[x, y].IsMine)
                {
                    board[x, y].IsMine = true;
                    mineNum++;
                }
            }

            // 全てのマスについて、周囲の地雷の数を数え、格納する
            for (int y = 0; y < BoardHeight; y++)
            {
                for (int x = 0; x < BoardWidth; x++)
                {
                    // そのマスが地雷の場合は、何もしない
                    if (board[x, y].IsMine)
                        continue;

                    // 地雷でないマスの場合、周囲の地雷の数を数え、格納する
                    board[x, y].NeighborMineCount = CountNeighborMines(x, y);
                }
            }
        }

        // 指定されたマスの周囲に地雷は何個あるか数える
        int CountNeighborMines(int x, int y)
        {
            int mineNum = 0;

            if (IsMine(x - 1, y - 1)) mineNum++; // 左上
            if (IsMine(x + 0, y - 1)) mineNum++; // 上
            if (IsMine(x + 1, y - 1)) mineNum++; // 右上
            if (IsMine(x - 1, y + 0)) mineNum++; // 左
            if (IsMine(x + 1, y + 0)) mineNum++; // 右
            if (IsMine(x - 1, y + 1)) mineNum++; // 左下
            if (IsMine(x + 0, y + 1)) mineNum++; // 下
            if (IsMine(x + 1, y + 1)) mineNum++; // 右下

            return mineNum;
        }

        // 指定されたマスが地雷か?
        bool IsMine(int x, int y)
        {
            return IsInsideBoard(x, y) && board[x, y].IsMine;
        }

        // 指定されたマスが盤面の内側か?
        bool IsInsideBoard(int x, int y)
        {
            return x >= 0 && x < BoardWidth && y >= 0 && y < BoardHeight;
        }

        // クリアか?
        // 地雷ではないマスが全て開かれればクリア、trueを返却する。
        bool IsCleared()
        {
            for (int y = 0; y < BoardHeight; y++)
            {
                for (int x = 0; x < BoardWidth; x++)
                {
                    Block block = board[x, y];

                    // 地雷ではないのに開かれていないということは未クリア
                    if (!block.IsMine && !block.Opened)
                        return false;
                }
            }
            // ここまで来たら、地雷ではないマスは全て開かれているということなので、クリア
            return true;
        }

        protected override void Draw(GameTime gameTime)
        {
            // 背景色
            Color backgroundColor;
            // 顔画像
            Texture2D face;

            // ゲームの状態によって、背景色と顔画像を変更する
            if (state == GameState.Playing)
            {
                backgroundColor = Color.CornflowerBlue;
                face = textureFaceNormal;
            }
            else if (state == GameState.Cleared)
            {
                backgroundColor = Color.Yellow;
                face = textureFaceSmile;
            }
            else
            {
                backgroundColor = Color.Red;
                face = textureFaceDying;
            }

            // 背景色で画面全体を塗りつぶす
            GraphicsDevice.Clear(backgroundColor);

            // SpriteBatchを使うために必要
            spriteBatch.Begin();

            // 顔画像を描画
            spriteBatch.Draw(face, new Vector2(WindowWidth / 2 - 8, 32), Color.White);

            // 盤面の描画
            for (int y = 0; y < BoardHeight; y++)
            {
                for (int x = 0; x < BoardWidth; x++)
                {
                    board[x, y].Draw(spriteBatch);
                }
            }

            // SpriteBatchを使うために必要
            spriteBatch.End();

            base.Draw(gameTime);
        }
    }
}
これで完成です!
クリア後やゲームオーバー後は、操作を不可能にしたり、画面表示を変えたりしたいので、現在ゲームがどのような状態なのかを表すためのenum、GameStateを作りました。

マインスイーパーは、地雷以外の場所を全て開ければクリアです。今回のプログラムでは、地雷ではないのにまだ開けてない場所がある場合は未クリア、そうでなければクリア、というふうに判定しています。

マインスイーパーの奥深さ…


現状、一発目でいきなり地雷が当たることがあります。こうなるともうゲームがうまいとかヘタとか関係ありません。さすがにこれではプレイヤーがかわいそすぎるので、市販の一般的なマインスイーパーは、こうならないような工夫が施されており、一発目は絶対に地雷が当たらないようになっています。……一体どうやっているのでしょうか?考えてみてください。そして実装してみてください!

一発目に地雷が出るという理不尽を排除したとしても、マインスイーパーには、まだ運の要素があります。絶対に論理的に解くことができず、勘で開けるしかない、という状況がたびたび発生するのです。そのような状況がゲーム終盤に発生し、そして運命の選択に失敗しようものなら、そのストレスはなかなかのものです。
多くのプレイヤーがこの理不尽さに不満を抱いていました。あるマインスイーパー廃人の人物も同じでしたが、彼はある日、こういった理不尽さを完全に排除したマインスイーパーのソフトを手に入れました。
さて、理不尽さが完全に排除されたマインスイーパーをプレイして、彼は幸せになったのでしょうか?

答えは「NO」です。

彼の感想は「何か物足りない」というものでした。

ゲームにとって理不尽さとは、必ずしもネガティブなものではなく、ときにスパイスとなることもあるのです。
なぜかって、そもそも人間が理不尽・不合理の塊だからですね。
(でもやっぱり基本的には理不尽なゲームは作ってはいけませんよ。大抵は開発者のひとりよがりのクソゲーになってしまいます)

2 件のコメント:

  1. Update関数の中のif (!IsInsideBoard(x, y))return;
    ですが、画面外でクリックするとバグが起こります。
    画面外でクリックすると、prevMouseがPressedになり、
    離した瞬間にmouseがReleasedになります。
    このときに、最初のif文である
    mouse.LeftButton == ButtonState.Released && prevMouse.LeftButton == ButtonState.Pressed
    を通り、画面外であるため
    if (!IsInsideBoard(x, y))return;
    でUpdate関数が終わってしまいます。
    prevMouse=mouse;がUpdateの終わりに実行されているため、一度クリックしたらprevMouseはPressedのまま、
    画面外に入るまでmouse.LeftButton == ButtonState.Released && prevMouse.LeftButton == ButtonState.Pressed
    が実行され続けるので、画面外に入れた瞬間にクリックされる判定になってしまいます。

    なのでreturnを使わない書き方をする必要があると思われます。

    返信削除
    返信
    1. ご指摘ありがとうございます!ごもっともでした…
      本文のサンプルプログラムを訂正しました。

      削除