C# – Hướng dẫn viết game Dò mìn (Minesweeper)

Minesweeper là một game mini được tích hợp sẵn trong Windows nên có lẽ ai cũng từng chơi qua vài lần. Và nếu bạn đang có hứng thú muốn tạo ra một game nhỏ để thử sức thì đây là một trong những lựa chọn thích hợp. Minesweeper có giao diện và luật chơi đơn giản, thuật toán cài đặt cũng dễ dàng và không tốn nhiều tài nguyên máy.

(Cập nhật chức năng click chuột bằng 2 nút trái phải 24/1/2011)

Download demo+sourcecode v1.0.1 (255KB)

Trong bài này tôi sử dụng C# để viết, bạn có thể sử dụng bất kì ngôn ngữ nào dựa theo cách thức tương tự mà tôi trình bày. Vì phần thuật toán khá đơn giản nên tôi sẽ tập trung vào phần giao diện coi như hướng dẫn cách thiết kế giao diện cơ bản.

Y2 MineLand 1.0

Y2 MineLand 1.0

Luật chơi

Minisweeper (của Windows) có phần giao diện chính là một bảng các ô vuông xếp liền nhau tạo thành một hình chữ nhật có chiều rộng và dài tối thiểu là 9 ô (đơn vị là ô vuông) và số mìn tối thiểu là 10.

Trong bảng này sẽ có các ô được đặt mìn ngẫu nhiên và nhiệm vụ của người chơi là mở tất cả các ô không có mìn bằng cách click chuột trái vào các ô đó, khi chỉ còn các ô có mìn còn lại thì kết thúc màn chơi.

Các trạng thái của một ô

Tùy theo quá trình khởi tạo và thao tác của người dùng mà các ô trong bảng có thể có một hoặc vài trạng thái trong các trạng thái sau:

–       Có mìn: được đặt ngẫu nhiên lúc khởi tạo

–       Đã mở: Khi người dùng nhấn chuột trái vào ô

–       Được cắm cờ: Khi người dùng nhấn phải vào ô

–       Được đánh dấu: Khi nhấn phải vào ô đã được “cắm cờ”

–       Bình thường: không có tất cả các trạng thái trên

Các trường hợp khi mở một ô

Khi mở một ô X nào đó, có 3 trường hợp có thể xảy ra:

  1. X có mìn: hiện tất cả mìn trong bảng ra và ‘game over’.
  2. X không có mìn nhưng 8 ô xung quanh có mìn: hiện số mìn xung quanh vào X.
  3. X không có mìn và xung quanh cũng không có mìn: mở lần lượt các ô xung quanh X cho đến khi gặp các trường hợp 1 và 2.

Cắm cờ và đánh dấu

Minisweeper cho phép bạn đánh dấu các ô nghi ngờ có mìn bằng cách “cắm cờ” và “đánh dấu”. Khi bạn “cắm cờ”, tức là bạn xác định rằng ô đó có mìn và ô đó được hiển thị là một lá cờ. Bạn không thể mở ô đó bằng chuột trái được. Bạn chỉ có thể hủy bỏ trạng thái “cắm cờ” bằng cách click chuột phải, tùy theo thiết lập mà ô đó sẽ chuyển sang trại thái “đánh dấu” hoặc “bình thường”.

Khi bạn “đánh dấu”, tức là bạn đoán rằng ô đó có thể có mìn nhưng không chắc chắn.

Xây dựng chương trình

Như các project trước tôi đã làm, ta sẽ tách các phần logic và UI và ra để dễ nâng cấp khi cần thiết.

Phần Bussineses

Phần này tôi thiết kế một lớp Cell và lớp MinesBoard đại diện cho ô và bảng mìn trong trò chơi.

Lớp Cell dựa trên các trạng thái của một ô, bạn có thể sử dụng enum thay cho class, trong bài này tôi sử dụng class để người đọc dễ hiểu. Lớp này chỉ bao gồm các field (bạn có thể dùng Property cho “máy móc”) sau:

public class Cell
{
    public bool IsMine = false;
    public bool IsOpened = false;
    public bool IsFlag = false;
    public bool IsMarked = false;
    public int  MinesAround = 0;
}

Lớp MinesBoard sẽ chứa một mảng hai chiều các đối tượng kiểu Cell, ngoài ra còn chứa dữ liệu cần thiết khác như số dòng, số cột, số mìn, số lá cờ được cắm, số ô đã được mở và hai trạng thái thắng, thua.

Note: Trong bài này, tôi dùng Rows và Cols thay cho Height và Width để tránh nhầm lẫn khi làm việc với mảng hai chiều. Bạn có thể hiểu Rows là Height và Cols là Width.

Việc khởi tạo MinesBoard bạn sẽ lặp vào random đánh dấu trạng thái IsMine của các Cell là true:

private void InitBoard()
{
    // […]

    while (count < _MinesCount)
    {
        int index = rnd.Next(_CellsCount);
        int r = index / _Cols;
        int c = index % _Cols;

        if (!_cells[r, c].IsMine)
        {
            _cells[r, c].IsMine = true;
            count++;
        }
    }
}

Tiếp đến ta cần viết một phương thức để “mở” một ô trong bảng, như đã giới thiệu trong phần luật chơi, bạn phải kiểm tra các trạng thái của ô đó và quyết định sẽ làm gì. Trong trường hợp thứ 3 bạn phải dùng đệ quy để duyệt và mở các ô xung quanh. Nhưng trước khi viết phương thức này, ta hãy xem một chút về phương pháp duyệt các ô để đếm số mìn quanh một ô.

private int CountAroundMines(int row, int col)
{
    int count = 0;
    int r1 = row == 0 ? 0 : -1;
    int c1 = col == 0 ? 0 : -1;
    int r2 = row == _Rows-1 ? 1 : 2;
    int c2 = col == _Cols-1 ? 1 : 2;

    for (; r1 < r2; r1++)
        for (int j=c1; j < c2; j++)
        {
            if (_cells[row + r1, col + j].IsMine)
                count++;
        }
    return count;
}

Như bạn thấy trong đoạn mã trên thì r1c1 tương ứng cho giá trị của dòng và cột đầu tiên, r2c2 cho dòng và cột cuối cùng ta sẽ lặp qua. Đây chỉ là vị trí tương đối và nếu ô đang xét không nằm ở biên của bảng thì r1 và c1 sẽ có giá trị là -1, r2 và c2 có giá trị là 2. Tức là bạn có thể lặp tối đa từ -1 đến 1, cộng các giá trị này với vị trí row và col để duyệt tất cả 9 ô xung quanh ô hiện tại.

(Ở đây bạn có thể thêm phần kiểm tra nếu r1=0, j=0 (trong đoạn mã trên) tức là ô duyệt đến là ô hiện tại, bạn có thể bỏ qua không xét ô này)

Bạn đã biết cách cách lặp qua các ô xung quanh một ô, bây giờ là phần viết lệnh để mở một ô. Hãy xem hình minh họa sau để hiểu cách thức mà mã lệnh của chúng ta sẽ thực hiện:

Y2 MineLand Algorithm Demo

/// <summary>
///
/// </summary>
/// <param name="row"></param>
/// <param name="col"></param>
/// <returns>true nếu trúng mìn</returns>
public bool OpenCell(int row, int col)
{
    if (_cells[row, col].IsOpened || _cells[row, col].IsFlag)
        return false;
    _cells[row, col].IsOpened = true;

    if (_cells[row, col].IsMine)
    {
        return true;
    }
    _OpenedCellsCount++;

    // Đếm số mìn xung quanh và kiểm tra các trường hợp
    int count = CountAroundMines(row, col);

    if (count > 0)
    {
        _cells[row, col].MinesAround = count;
    }
    else
    {
        int r1 = row == 0 ? 0 : -1;
        int c1 = col == 0 ? 0 : -1;
        int r2 = row == _Rows - 1 ? 1 : 2;
        int c2 = col == _Cols - 1 ? 1 : 2;

        for (; r1 < r2; r1++)
            for (int j = c1; j < c2; j++)
            {
                OpenCell(row + r1, col + j);
            }
    }
    return false;
}

Đó là những vấn đề chính của phần bussiness này, bạn có áp dụng dùng nó để tạo nên phần “nhân” cho một WindowsForms App hoặc Console App bất kì của trò chơi này.

Phần Presentation

Thông thường tôi sẽ tạo một UserControl để “tạo hình” cho lớp MinesBoard trên. Bạn có thể hiểu lớp MinesBoard trên là “hồn” và cần gắn vào một cái “xác” để nó có thể hoạt động được. Ở đây tôi đặt tên lớp này là MinesBoardUI. Tôi đặt tên giống nhau như vậy để bạn hiểu rằng không nên thiết kế và gắn thêm quá nhiều thứ vào lớp này ngoài những chức năng mà MinesBoard đã có sẵn.

Vậy ta chỉ cần đơn giản là tạo lớp này để nó vẽ ra một cái bảng đồng thời cập nhật những trạng thái của các ô thành hình ảnh để người dùng có thể thấy và thao tác được. Nếu bạn gắn thêm những thứ khác như button, label để thực thi lệnh gì đó hay để hiển thị điểm thì bạn phải thiết kế lại UserControl này mỗi lần muốn nâng cấp chương trình. Giả sử nó là một DLL ngoài để cho người khác dùng thì sẽ rất bất tiện.

Vẽ giao diện

Khi thiết kế lớp này, bạn chỉ cần tập trung vào hai phần chính: Hiển thị và xử lý thao tác của người dùng. Hai sự kiện tương ứng mà tôi chọn là Paint và MouseDown. Hãy override các sự phương thức tương ứng của hai sự kiện trên. Các đoạn code sau sẽ thay phần giải thích của tôi, bạn có thể thấy hơi dài và rối, tuy nhiên nó không phức tạp mà chỉ đơn giản là kiểm tra từng trạng thái của các ô nên rất dễ hiểu.

protected override void OnPaint(PaintEventArgs e)
{
    e.Graphics.FillRectangle(Brushes.LightGray, 0, 0, this.Width, this.Height);

    for (int i = 0; i < _board._Rows; i++)
    {
        int y = CELL_SIZE * i;

        for (int j = 0; j < _board._Cols; j++)
        {
            int x = CELL_SIZE * j;

            if (_board[i, j].IsOpened)
            {
                if (_board[i, j].IsMine)
                {
                    e.Graphics.FillRectangle(Brushes.Red, x, y, CELL_SIZE, CELL_SIZE);
                    e.Graphics.DrawImage(_imgBomb, x, y);
                }
                else if (_board[i, j].MinesAround > 0)
                {
                    string s = _board[i, j].MinesAround.ToString();
                    SizeF size = e.Graphics.MeasureString(s, this.Font);

                    e.Graphics.DrawString(s,
                        this.Font, new SolidBrush(_foreColors[_board[i, j].MinesAround - 1]),
                            x + (CELL_SIZE - size.Width) / 2, y + (CELL_SIZE - size.Height) / 2);
                }
            }
            else
                e.Graphics.DrawImage(_imgCell, x, y);

            if (_board._IsLost)
            {
                if (_board[i, j].IsMine)
                    e.Graphics.DrawImage(_imgBomb, x, y);
            }

            if (_board[i, j].IsFlag)
            {
                e.Graphics.DrawImage(_imgFlag, x, y);
            }
            else if (_board[i, j].IsMarked)
            {
                string s = "?";
                SizeF size = e.Graphics.MeasureString(s, this.Font);

                e.Graphics.DrawString(s,
                    this.Font, Brushes.Black,
                        x + (CELL_SIZE - size.Width) / 2, y + (CELL_SIZE - size.Height) / 2);

            }
            // vertical
            if(i==0)
            e.Graphics.DrawLine(Pens.Gray, x, 0, x, this.Height);
        }

        // hoz
        e.Graphics.DrawLine(Pens.Gray, 0, y, this.Width, y);
    }

    base.OnPaint(e);
}

Có một phương thức của lớp Graphisc có thể bạn thắc mắc là MeasureString(). Đây là phương thức để lấy về kích thước của một chuỗi dựa trên Font dùng để viết chuỗi đó. Tôi dùng phương thức này để tính toán và vẽ để chuỗi hiển thị chính giữa ô.

Bạn có thể thấy là trong Minesweeper, mỗi một con số có giá trị khác nhau hiển thị trong ô vuông sẽ có màu sắc khác nhau. Ta làm việc này đơn giản bằng cách tạo một mảng các đối tượng Color, và truy xuất dựa theo giá trị của số:

Color[] _foreColors = {
    Color.Blue,Color.Green,Color.Red,Color.Purple,Color.Peru,
    Color.PaleGreen,Color.Orchid,Color.Olive};
Để tạo một Brush để vẽ dựa theo màu, bạn dùng đối tượng SolidBrush. Như trong đoạn mã trên:
e.Graphics.DrawString(s,
    this.Font, new SolidBrush(_foreColors[ số mìn của ô đang xét - 1]),
        x + (CELL_SIZE - size.Width) / 2, y + (CELL_SIZE - size.Height) / 2);

Để tạo một Brush để vẽ dựa theo màu, bạn dùng đối tượng SolidBrush. Như trong đoạn mã trên:

e.Graphics.DrawString(s,
    this.Font, new SolidBrush(_foreColors[ số mìn của ô đang xét - 1]),
        x + (CELL_SIZE - size.Width) / 2, y + (CELL_SIZE - size.Height) / 2);

Xử lý thao tác người dùng

 

protected override void OnMouseDown(MouseEventArgs e)
{
    if (!_board._IsLost && !_board._IsFinish)
    {
        int c = e.X / CELL_SIZE;
        int r = e.Y / CELL_SIZE;

        if (_board[r, c].IsOpened)
            return;

        if (!_board[r, c].IsFlag && e.Button == MouseButtons.Left)
        {
            _board[r, c].IsMarked = false;
            if (_board.OpenCell(r, c))
            {
                _board._IsLost = true;

                pictureBox1.Left = e.X - pictureBox1.Width / 2;
                pictureBox1.Top = e.Y - pictureBox1.Height / 2;
                pictureBox1.Visible = true;
                timer1.Enabled = true;
                // Dùng cho event
                OnMinesExplode();

            }
            else
            {
                // Win
                if (RemainCellsCount == MinesCount)
                {
                    _board._IsFinish = true;

                    // Cắm cờ tất cả ô còn lại
                    for (int i = 0; i < Rows; i++)
                    {
                        for (int j = 0; j < Cols; j++)
                        {
                            if (!_board[i, j].IsOpened)
                                _board[i, j].IsFlag = true;
                        }
                    }
                }
            }

            Invalidate();
        }
        else if (e.Button == MouseButtons.Right)
        {
            if (_board[r, c].IsMarked)
            {
                _board[r, c].IsMarked = false;
            }
            else
            {
                _board[r, c].IsFlag = !_board[r, c].IsFlag;
                _board[r, c].IsMarked = !_board[r, c].IsFlag;

                if (_board[r, c].IsFlag)
                    _board._FlagsCount++;
                else
                    _board._FlagsCount--;
            }
            Invalidate();

        }
        // Dùng cho event
        OnCellClick();
    }
    base.OnMouseDown(e);
}

Thêm các Event
Để hoàn tất UserControl này, bạn cần thêm các event để từ Form ta có thể xử lý trong những trường hợp như đạp trúng mìn, mở một ô,… Đây là đoạn mã lệnh tương ứng để tạo các event cho lớp này. Bạn có thể thấy chúng được kích hoạt trong đoạn mã OnMouseDown trên:

public event EventHandler CellClick;
public event EventHandler MinesExplode;
#region CustomEvent

private void OnCellClick()
{
    if (CellClick != null)
        CellClick(this, null);
}
protected void OnMinesExplode()
{
    if (MinesExplode != null)
        MinesExplode(this, null);
}

#endregion

 

Bài tập cho người đọc

Với những gì tôi trình bày ở trên, bạn có thể theo ý tưởng đó và tạo ra một game Dò mìn mà không cần source code đầy đủ của project, chỉ mất khoảng 3,4 tiếng để bạn có thể hoàn thành phần cơ bản.

Project tôi cung cấp ở đầu bài chưa hoàn chỉnh và còn thiếu chức năng đếm giờ, lưu điểm người chơi. Ngoài hai chức  năng trên bạn có thể làm thêm một số yêu cầu như bài tập để tăng cường kĩ năng lập trình của mình.

–       Thêm chức năng tính giờ cho chương trình bằng cách dùng Timer.

–       Thêm chức năng lưu điểm người chơi bằng file text hoặc file nhị phân. Hiển thị những người chơi có số điểm cao nhất (cài đặt bằng một loại collection).

–       Lưu trạng thái của trò chơi để có thể chơi lại lần sau (tương tự cách trên)/

–       Cho phép người dùng thay đổi hình nền của bảng dò mìn, sử dụng thuộc tính  BackgroundImage hoặc dùng Graphisc để vẽ trực tiếp.

–       Thêm dòng chữ “Congratulation” và “Game Over” khi người chơi thắng hoặc thua lên màn hình (sử dụng picturebox chứa ảnh gif).

–       Sử dụng hàm ShellAbout của Windows API để hiển thị hôp thoại About như hình dưới. Khai báo hàm này như sau:

[DllImport("shell32.dll")]
static extern int ShellAbout(IntPtr hWnd, string szApp, string szOtherStuff, IntPtr hIcon);

Bạn có thể xem thêm bài này để tìm hiểu về attribute DllImport này. ShellAbout

https://yinyangit.wordpress.com

Advertisements

20 thoughts on “C# – Hướng dẫn viết game Dò mìn (Minesweeper)

  1. Cửa sổ Console là cửa sổ dòng lệnh ko hỗ trợ các đối tượng đồ hoạ như Windows Form. Bạn có thể viết bằng Console bằng cách in ra những kí tự nhưng làm bằng Windows Form sẽ có giao diện đẹp hơn.

    Phản hồi
    • Để làm thời gian thì bạn chỉ cần dùng 1 biến lưu số giây (ban đầu = 0). Trong sự kiện Tick của timer (với interval = 1000), bạn tăng số giây lên 1, từ đó có thể tính ra số phút nếu muốn.

      Về phần chiến thắng thì bạn chỉ cần kiểm tra kết quả (minesBoard1.RemainCellsCount == minesBoard1.MinesCount). Bạn có thể sửa lại phương thức UpdateMines() của Form1 để hiển thị một MessageBox thay thế hoặc một form thông báo nào đó.

      Phản hồi

Trả lời

Mời bạn điền thông tin vào ô dưới đây hoặc kích vào một biểu tượng để đăng nhập:

WordPress.com Logo

Bạn đang bình luận bằng tài khoản WordPress.com Log Out / Thay đổi )

Twitter picture

Bạn đang bình luận bằng tài khoản Twitter Log Out / Thay đổi )

Facebook photo

Bạn đang bình luận bằng tài khoản Facebook Log Out / Thay đổi )

Google+ photo

Bạn đang bình luận bằng tài khoản Google+ Log Out / Thay đổi )

Connecting to %s