10yroの開発日記

福岡にある株式会社10yro(トイロ)のエンジニアが書いています

マインスイーパーを作ってみた

お手頃なゲームを作りたいと思いお題を探したらふとマインスイーパーを思い出したので、C#で作ってみました。
昔はwindowsにも標準搭載されてたあれです。

まずはルール確認(wikipediaより引用 マインスイーパ - Wikipedia )

ゲーム画面は正方形のマスが敷き詰められた長方形のフィールドから構成されている。それぞれのマスは開けることができるが、地雷の置かれているマスを開けると負けとなる。地雷の置かれていないマスを開けたときは、隣接する8方向のマスのいずれかに地雷がある場合はその個数が表示され、隣接するマスに地雷が置かれていないときは、それらが自動的に開けられる。地雷の置かれていないマスをすべて開ければ勝ちとなる。

ルールは仕様は下記とします。

  • 今回は、爆弾のマスは9x9の爆弾の数は10個とする。
  • 各マスは、「爆弾」または「隣接する爆弾の数」で構成される。
  • 爆弾のマスを開いた場合はゲーム終了(負け)とする。
  • すべての爆弾が置かれていないマスを開いた場合はゲーム終了(勝ち)とする。
    • 開いてないマスが全て爆弾マスの場合でもゲーム終了とする。
  • 開いたマスが爆弾のないマスの場合は、隣接する爆弾の数を表示する。
    • →隣接する爆弾がない場合は、隣接する爆弾が存在するマスまで自動的に表示する。
  • 開いたマスの数字と周囲の旗の数が一致している状態で、数字マスをクリックした場合は、周囲のマスを全て空ける。(便利機能)

開発言語

  • .net 8.0 C#
  • タイマー等のデジタル表示には、「SevenSegment 1.0.0」を利用してます。

とりあえず、完成イメージ

ダウンロード

ソース全体は、

github.com

まずはブロック(マス)を作成
ラベルを継承して、作成してます。
ブロックの状態を管理して、状態が変更された際に適切な描画をするようにしています。
画像等は爆弾と旗はここから貰った画像を加工して使ってそれ以外は文字で表示してます。

using minesweeper.Properties;

namespace minesweeper
{
    enum BlockState
    {
        Opened, // 開いた状態
        Closed, // 閉じた状態
        Flagged, // 旗が立てられた状態
        Flagged_NG, // 旗が間違って立てられた状態
        Bomb, // 爆弾のブロック
        Bomb_OPENDED, // 開いた爆弾のブロック
    }
    internal class Positon(int x, int y)
    {
        public int X { get; set; } = x;
        public int Y { get; set; } = y;
    }
    internal class Block : Label
    {
        public Block()
        {
            TextAlign = ContentAlignment.MiddleCenter;
            BackgroundImageLayout = ImageLayout.Stretch;
            Font = new Font("Arial", 15, FontStyle.Bold);
            AutoSize = false;
            SetImage();
        }

        private bool _IsPressed = false;
        public bool _IsOpened = false;
        private BlockState state = BlockState.Closed;
        // ブロックの状態
        public BlockState State
        {
            get { return state; }
            set
            {
                state = value;
                SetImage();
            }
        }
        // 周辺の爆弾の数
        public int BombCount = 0;
        // 爆弾のブロックかどうか
        public bool IsBomb = false;
        // ブロックの位置
        public Positon Positon = new Positon(0, 0);
        // ブロックが押されたかどうか
        public bool IsPressed
        {
            get { return _IsPressed; }
            set
            {
                _IsPressed = value;
                SetImage();
            }
        }
        // ブロックの画像を設定
        private void SetImage()
        {
            if (_IsPressed)
            {
                BackgroundImage = Resources.block_pressed;
                return;
            }
            switch (state)
            {
                case BlockState.Opened:
                    BackgroundImage = Resources.block_pressed;
                    SetBombCountText();
                    break;
                case BlockState.Closed:
                    BackgroundImage = Resources.block;
                    break;
                case BlockState.Flagged:
                    BackgroundImage = Resources.flag;
                    break;
                case BlockState.Flagged_NG:
                    BackgroundImage = Resources.flag;
                    Text = "X";
                    Font = new Font("Arial", 18, FontStyle.Bold);
                    ForeColor = Color.Red;
                    break;
                case BlockState.Bomb:
                    BackgroundImage = Resources.bomb;
                    BackColor = ColorTranslator.FromHtml("#c6c6c6");
                    break;
                case BlockState.Bomb_OPENDED:
                    BackgroundImage = Resources.bomb;
                    BackColor = Color.Red;
                    break;
            }
        }
        private void SetBombCountText()
        {
            if (BombCount == 0) return;
            Text = BombCount.ToString();
            switch (BombCount)
            {
                case 1:
                    ForeColor = ColorTranslator.FromHtml("#0000F7");
                    break;
                case 2:
                    ForeColor = ColorTranslator.FromHtml("#007C00");
                    break;
                case 3:
                    ForeColor = ColorTranslator.FromHtml("#EC1F1F");
                    break;
                case 4:
                    ForeColor = ColorTranslator.FromHtml("#00007C");
                    break;
                case 5:
                    ForeColor = ColorTranslator.FromHtml("#7C0000");
                    break;
                case 6:
                    ForeColor = ColorTranslator.FromHtml("#007C7C");
                    break;
                case 7:
                    ForeColor = ColorTranslator.FromHtml("#000000");
                    break;
                case 8:
                    ForeColor = ColorTranslator.FromHtml("#7C7C7C");
                    break;

            }
        }
    }
}

次はゲーム画面の作成

InitGameFiled()で盤面を作成しています。
動的に上記で作成したBlockを並べてランダムで爆弾の位置を設定しています。
細かい動作はソースコメントで。
ドラッグ中はドラッグを開始したコントロールが以外イベントが発生しないようなので、
マウス座標から現在のコントロール(マス)を特定するようにしてます。

using minesweeper.Properties;
using Timer = System.Windows.Forms.Timer;

namespace minesweeper
{
    public partial class Form1 : Form
    {
        enum GameStatus
        {
            Ready,
            Playing,
            GameEnd,
        }
        // 横のブロック数
        const int xLength = 9;
        // 縦のブロック数
        const int yLength = 9;
        // 爆弾の数
        const int bombCount = 10;
        // 1ブロックのサイズ
        const int BlockSixe = 50;
        // タイマー
        Timer timer = new Timer();

        GameStatus gameState = GameStatus.Ready;

        public Form1()
        {
            InitializeComponent();
        }

        private void Form1_Load(object sender, EventArgs e)
        {
            timer.Interval = 1000; // 1秒
            txtBombCount.Value = "";
            timer.Tick += Timer_tick;
            InitGameFiled();
        }

        /// <summary>
        /// ゲームの経過時間のタイマー
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        void Timer_tick(object? sender, EventArgs e)
        {
            this.txtBombTimer.Value = (int.Parse(this.txtBombTimer.Value) + 1).ToString();
        }

        /// <summary>
        /// ゲームフィールドの初期化
        /// </summary>
        private void InitGameFiled()
        {
            this.txtBombCount.Value = bombCount.ToString();
            this.txtBombTimer.Value = "0";
            timer.Stop();
            this.gameState = GameStatus.Ready;
            blockArea.Controls.Clear();
            for (int x = 0; x < xLength; x++)
            {
                for (int y = 0; y < yLength; y++)
                {
                    var label = new Block();
                    label.Name = "Block" + x + y;
                    label.Size = new Size(BlockSixe, BlockSixe);
                    label.Location = new Point(x * BlockSixe, y * BlockSixe);
                    label.Positon = new Positon(x, y);
                    label.MouseUp += Block_MouseUp;
                    // マウスダウン時のイベント。
                    label.MouseDown += (sender, e) =>
                    {
                        if (this.gameState == GameStatus.GameEnd) return;
                        if ((e.Button & MouseButtons.Left) != MouseButtons.Left) return;
                        // ゲーム開始
                        if (this.gameState == GameStatus.Ready)
                        {
                            this.gameState = GameStatus.Playing;
                            timer.Start();
                        }
                        PressBlock(label);
                    };
                    label.MouseMove += Block_MouseMove;

                    blockArea.Controls.Add(label);
                }
            }
            // 爆弾の位置をランダムに決定
            Random rnd = new Random();
            var setBombCount = 0;
            while (setBombCount != bombCount)
            {
                int x = rnd.Next(0, xLength);
                int y = rnd.Next(0, yLength);
                var block = (Block?)blockArea.Controls["Block" + x + y];

                // 既に設定されている場合はスキップ
                if (block.IsBomb)
                {
                    continue;
                }
                setBombCount++;
                block.IsBomb = true;
            }

            // 爆弾の周囲のマスに数字を表示
            foreach (Control control in blockArea.Controls)
            {
                if (control == null && (control is not Block)) return;
                Block block = (Block)control;
                foreach (var target in GetAroundBlok(block))
                {
                    if (target.IsBomb) block.BombCount++;
                }
            }

            this.Size = new Size(BlockSixe * xLength + 20, BlockSixe * yLength + infoArea.Height + SystemInformation.CaptionHeight + 20);
            this.FormBorderStyle = FormBorderStyle.FixedSingle;
        }

        /// <summary>
        /// 指定したブロックの周囲のブロックを取得
        /// </summary>
        /// <param name="block">基準のブロック</param>
        /// <returns></returns>
        private List<Block> GetAroundBlok(Block block)
        {
            List<Block> blocks = new List<Block>();
            for (int x = -1; x <= 1; x++)
            {
                for (int y = -1; y <= 1; y++)
                {
                    if (block.Positon.X + x < 0 || block.Positon.X + x >= xLength || block.Positon.Y + y < 0 || block.Positon.Y + y >= yLength) continue;
                    var target = (Block?)blockArea.Controls["Block" + (block.Positon.X + x) + (block.Positon.Y + y)];
                    if (target == null) return blocks;
                    blocks.Add(target);
                }
            }
            return blocks;
        }

        /// <summary>
        /// 現在のマウスカーソルにあるブロックを取得
        /// </summary>
        /// <returns>対象のブロックのリスト</returns>
        private Block? GetActiveBlock()
        {
            Control? control = blockArea.GetChildAtPoint(blockArea.PointToClient(Cursor.Position));
            if (control == null && (control is not Block)) return null;
            return (Block)control;
        }

        /// <summary>
        /// マウスアップ時のイベント
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void Block_MouseUp(object? sender, MouseEventArgs e)
        {
            if (this.gameState == GameStatus.GameEnd) return;
            ResetPreessedBlock(null);
            var block = this.GetActiveBlock();
            if (block == null) return;

            // 左クリックで旗以外をクリックした時
            if ((e.Button & MouseButtons.Left) == MouseButtons.Left && block.State != BlockState.Flagged)
            {
                // 爆弾の場合ゲームオーバー
                if (block.IsBomb)
                {
                    GameOver(block);
                    return;
                }

                // 数字ブロックの場合は一括で開く
                if (block.State == BlockState.Opened && CanBlockOpen(block))
                {
                    OpenBlock(block, true);
                    CheckGameClear();
                    return;
                }

                if (block.BombCount == 0)
                {
                    OpenBlock(block, false);
                }
                else
                {
                    block.State = BlockState.Opened;
                }
            }

            // 右クリックの時は旗をON・OFFする
            if ((e.Button & MouseButtons.Right) == MouseButtons.Right && block.State != BlockState.Opened)
            {
                if (block.State == BlockState.Flagged)
                {
                    block.State = BlockState.Closed;
                }
                else
                {
                    block.State = BlockState.Flagged;
                }
            }

            // クリア判定
            CheckGameClear();
        }

        /// <summary>
        /// 左クリック押した状態でマウス移動時はブロックを押下状態にする
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void Block_MouseMove(object? sender, MouseEventArgs e)
        {
            if (this.gameState == GameStatus.GameEnd) return;
            if ((Control.MouseButtons & MouseButtons.Left) != MouseButtons.Left) return;
            var block = GetActiveBlock();
            if (block == null) return;
            if (block.State == BlockState.Opened) return;
            block.IsPressed = true;
            ResetPreessedBlock(block);
        }

        /// <summary>
        /// ブロックの押下状態をリセット
        /// </summary>
        /// <param name="current">対象外にするブロック</param>
        private void ResetPreessedBlock(Block? current)
        {
            foreach (Control control in blockArea.Controls)
            {
                if (control == null && (control is not Block)) return;
                Block block = (Block)control;
                if (block.State == BlockState.Closed && current != control)
                {
                    ((Block)control).IsPressed = false;
                }
            }
        }

        /// <summary>
        /// ブロックを押す(クリックが確定してない情報)
        /// </summary>
        /// <param name="block"></param>
        private void PressBlock(Block block)
        {
            if (block == null) return;
            if (block.State == BlockState.Closed)
            {
                block.IsPressed = true;
            }
            else
            {
                foreach (var target in GetAroundBlok(block))
                {
                    if (target.State == BlockState.Closed)
                    {
                        target.IsPressed = true;
                    }
                }
            }
        }
        /// <summary>
        /// 周囲のブロックを開く。
        /// </summary>
        /// <param name="block">基準のブロック</param>
        /// <param name="isOpenBomb">爆弾のあるブロックは開くか</param>
        private void OpenBlock(Block block, bool isOpenBomb)
        {
            if (block == null) return;

            foreach (var target in GetAroundBlok(block))
            {
                if (target.State != BlockState.Closed) continue;
                if (isOpenBomb)
                {
                    if (target.IsBomb)
                    {
                        GameOver(target);
                        return;
                    }
                }
                else
                {
                    if (target.IsBomb) continue;
                }

                target.State = BlockState.Opened;
                if (target.BombCount == 0)
                {
                    OpenBlock(target, isOpenBomb);
                }
            }
        }

        /// <summary>
        /// 旗と周囲の爆弾の数が一致しているかどうか
        /// </summary>
        /// <param name="block"></param>
        /// <returns></returns>
        private Boolean CanBlockOpen(Block block)
        {
            if (block == null) return false;
            int flagCount = 0;
            foreach (var target in GetAroundBlok(block))
            {
                if (target.State == BlockState.Flagged)
                {
                    flagCount++;
                }
            }
            return block.BombCount == flagCount;
        }

        /// <summary>
        /// ゲームクリアかどうか
        /// </summary>
        private void CheckGameClear()
        {
            int flagCount = 0;
            int openCount = 0;
            int unmacthCount = 0;
            foreach (Control control in blockArea.Controls)
            {
                if (control == null && (control is not Block)) return;
                Block block = (Block)control;
                if (block.State == BlockState.Flagged)
                {
                    flagCount++;
                }
                if (block.State == BlockState.Opened)
                {
                    openCount++;
                }

                if (block.State == BlockState.Flagged && !block.IsBomb)
                {
                    unmacthCount++;
                }
            }
            // 間違った旗の数がないかつ全ての爆弾以外のブロックが開かれている場合はクリア
            if (unmacthCount == 0 && openCount == xLength * yLength - bombCount)
            {
                timer.Stop();
                this.btnGame.BackgroundImage = Resources.button_clear;
                this.gameState = GameStatus.GameEnd;

                foreach (Control control in blockArea.Controls)
                {
                    if (control == null && (control is not Block)) return;
                    Block block = (Block)control;
                    if (block.IsBomb)
                    {
                        block.State = BlockState.Flagged;
                    }
                }
                this.txtBombCount.Value = "0";
            }
            else
            {
                this.txtBombCount.Value = (bombCount - flagCount).ToString();
            }
        }

        /// <summary>
        /// ゲームオーバー
        /// </summary>
        /// <param name="currentBlock"></param>
        private void GameOver(Block currentBlock)
        {
            timer.Stop();
            foreach (Control control in blockArea.Controls)
            {
                if (control == null && (control is not Block)) return;
                Block block = (Block)control;

                // 爆発したブロックを赤くする
                if (block == currentBlock)
                {
                    block.State = BlockState.Bomb_Opened;
                    continue;
                }

                // 間違いの旗を表示
                if (block.State == BlockState.Flagged && !block.IsBomb)
                {
                    block.State = BlockState.Flagged_NG;
                    continue;
                }

                // 未開放の爆弾の位置を表示
                if (block.State != BlockState.Flagged && block.IsBomb)
                {
                    block.State = BlockState.Bomb;
                    continue;
                }
                // 閉じているブロックを開く
                if (block.State == BlockState.Closed)
                {
                    block.State = BlockState.Opened;
                    continue;
                }
            }
            this.btnGame.BackgroundImage = Resources.button_gameorver;
            this.gameState = GameStatus.GameEnd;
        }

        /// <summary>
        /// ゲームリセット
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void btnGame_Click(object sender, EventArgs e)
        {
            InitGameFiled();
            this.btnGame.BackgroundImage = Resources.button_default;
        }
    }
}