Xây dựng Game Minesweeper với Canvas API

Việc học và làm quen với các công nghệ mới luôn mang lại những trải nghiệm thú vị, đặc biệt là khi khám phá ra những khả năng tiềm ẩn của chúng. Canvas API trong phát triển front-end, với khả năng vẽ đồ họa và xử lý hình ảnh mạnh mẽ, là một ví dụ điển hình. Bài viết này sẽ hướng dẫn bạn cách xây dựng một trò chơi Minesweeper (Gỡ mìn) kinh điển bằng Canvas.

Tại sao lại là Minesweeper?

Minesweeper, ra mắt năm 1992, là một trò chơi giải đố kinh điển. Mục tiêu của người chơi là xác định vị trí tất cả các quả mìn trên một bàn cờ trong thời gian ngắn nhất, bằng cách sử dụng các số hiển thị trên các ô đã mở để suy luận. Việc click nhầm vào một quả mìn sẽ khiến trò chơi kết thúc. Theo truyền thuyết, trò chơi này được tạo ra ban đầu nhằm giúp người dùng máy tính mới làm quen với thao tác chuột trái, chuột phải và di chuyển con trỏ. Tuy nhiên, nó đã nhanh chóng trở thành một trò chơi hấp dẫn với lối chơi đơn giản nhưng gây nghiện.

Quy tắc cơ bản của Minesweeper

  • Thao tác: Bàn cờ bao gồm nhiều ô vuông. Chuột trái dùng để mở một ô, chuột phải dùng để đánh dấu một ô.
  • Quy tắc 1: Thông thường, một ô có tối đa 8 ô xung quanh. Nếu một ô có mìn xung quanh, khi mở ô đó, một con số hiển thị số lượng mìn lân cận sẽ xuất hiện. Nếu không có mìn xung quanh, ô sẽ để trống.
  • Quy tắc 2: Sau khi đánh dấu một ô, ô đó sẽ không thể mở được (coi như đã loại trừ mìn). Người chơi có thể đánh dấu tùy ý.
  • Quy tắc 3: Nếu một ô được mở không có mìn xung quanh (số 0), các ô lân cận cũng sẽ tự động được mở. Quá trình này diễn ra liên tục (đệ quy) nếu các ô xung quanh cũng không có mìn.
  • Kết thúc trò chơi: Trò chơi kết thúc khi tất cả các quả mìn đã được đánh dấu đúng và tất cả các ô không phải mìn đã được mở (thắng). Nếu người chơi mở phải một ô có mìn (thua).

Ý tưởng triển khai

Mặc dù có thể tiếp cận theo hướng thủ tục, việc áp dụng lập trình hướng đối tượng (OOP) sẽ giúp cấu trúc code rõ ràng và dễ bảo trì hơn:

  • Đối tượng Square (Ô): Lưu trữ trạng thái của ô (đã mở, là mìn, đã đánh dấu...). Cung cấp các phương thức như `open()`, `toggleFlag()`.
  • Đối tượng Game (Trò chơi): Quản lý trạng thái chung của trò chơi (thắng, thua), lưu trữ thông tin về các ô. Cung cấp các phương thức như `startGame()`, `endGame()`, `openSquare()`.
  • Phương thức Render (Vẽ giao diện): Chịu trách nhiệm hiển thị trạng thái của các ô lên Canvas.
  • Observer Pattern (Mẫu hình Quan sát): Theo dõi sự thay đổi trạng thái của trò chơi và gọi lại các phương thức cập nhật giao diện.
  • Cấu hình và Logic khác: Bao gồm cài đặt trò chơi, trạng thái, các quy tắc xử lý.

Triển khai chi tiết

Bắt đầu với việc tạo đối tượng Canvas và xử lý các sự kiện chuột.

Cấu trúc HTML và JavaScript cơ bản

HTML sẽ chỉ đơn giản là một thẻ <canvas>:

<div>
  <canvas id="minesweeperCanvas" width="630" height="630"></canvas>
</div>

Trong JavaScript, chúng ta cần xử lý sự kiện chuột. Đặc biệt, chuột phải sẽ kích hoạt menu ngữ cảnh mặc định của trình duyệt. Chúng ta cần ngăn chặn điều này.

const canvasElement = document.getElementById('minesweeperCanvas');
const context = canvasElement.getContext('2d');

// Ngăn chặn menu chuột phải mặc định
canvasElement.addEventListener('contextmenu', (event) => {
    event.preventDefault();
});

// Xử lý sự kiện click chuột
canvasElement.addEventListener('mouseup', (event) => {
    const rect = canvasElement.getBoundingClientRect();
    const x = event.clientX - rect.left;
    const y = event.clientY - rect.top;

    // Tính toán tọa độ ô dựa trên kích thước ô
    const col = Math.floor(x / SQUARE_STYLE.width);
    const row = Math.floor(y / SQUARE_STYLE.height);

    if (event.button === 0) { // Chuột trái
        gameManager?.openSquare(col, row);
    } else if (event.button === 2) { // Chuột phải
        gameManager?.flagSquare(col, row);
    }
});

Lưu ý: Kích thước Canvas nên được thiết lập trong thuộc tính HTML (width, height) thay vì CSS để tránh bị biến dạng.

Cấu hình trò chơi

Một đối tượng cấu hình giúp quản lý kích thước bàn cờ và tỷ lệ xuất hiện mìn, giúp dễ dàng thay đổi độ khó.

const GAME_SETTINGS = {
    width: 10, // Số cột
    height: 10, // Số hàng
    mineProbability: 0.15, // Tỷ lệ mìn
};

const SQUARE_STYLE = {
    width: 0,
    height: 0,
    padding: 5, // Khoảng cách giữa các số và viền ô
    fontSize: 16,
    fontColor: '#333',
    mineColor: 'red',
    closedColor: '#3b1a8f',
    openedColor: '#F5F5F5',
    flagColor: 'yellow',
};

let GAME_STATE = {
    isGameOver: true,
};

Hàm vẽ ô

Hàm này chịu trách nhiệm vẽ từng ô dựa trên trạng thái và thông tin (nếu có).

function drawSquare(square: Square, col: number, row: number, adjacentMines?: number) {
    const xPos = col * SQUARE_STYLE.width;
    const yPos = row * SQUARE_STYLE.height;

    context.fillStyle = SQUARE_STYLE.closedColor; // Màu mặc định khi chưa mở

    switch (square.type) {
        case SquareType.CLOSED:
            context.fillStyle = SQUARE_STYLE.closedColor;
            break;
        case SquareType.OPENED:
            context.fillStyle = SQUARE_STYLE.openedColor;
            if (adjacentMines !== undefined && adjacentMines > 0) {
                context.font = `${SQUARE_STYLE.fontSize}px Arial`;
                context.fillStyle = SQUARE_STYLE.fontColor;
                context.fillText(
                    adjacentMines.toString(),
                    xPos + SQUARE_STYLE.padding,
                    yPos + SQUARE_STYLE.fontSize + SQUARE_STYLE.padding
                );
            }
            break;
        case SquareType.FLAGGED:
            context.fillStyle = SQUARE_STYLE.flagColor;
            break;
        case SquareType.MINE:
            context.fillStyle = SQUARE_STYLE.mineColor;
            break;
    }
    context.fillRect(xPos, yPos, SQUARE_STYLE.width, SQUARE_STYLE.height);
}

Lớp Square

Đại diện cho một ô trên bàn cờ.

enum SquareType {
    CLOSED,
    OPENED,
    FLAGGED,
    MINE,
}

class Square {
    public type: SquareType = SquareType.CLOSED;
    public isMine: boolean = false;

    constructor() {
        // Xác định xem ô này có phải là mìn dựa trên xác suất
        this.isMine = Math.random() < GAME_SETTINGS.mineProbability;
    }

    public open(): boolean {
        if (this.type === SquareType.CLOSED) {
            this.type = SquareType.OPENED;
            if (this.isMine) {
                this.type = SquareType.MINE;
                return false; // Mở trúng mìn
            }
            return true; // Mở thành công
        }
        return true; // Đã mở hoặc đánh dấu rồi
    }

    public toggleFlag(): void {
        if (this.type === SquareType.CLOSED) {
            this.type = SquareType.FLAGGED;
        } else if (this.type === SquareType.FLAGGED) {
            this.type = SquareType.CLOSED;
        }
    }
}

Hàm tạo bàn cờ

Tạo một mảng 2 chiều chứa các đối tượng Square.

function createBoard(width: number, height: number): Square[][] {
    const board: Square[][] = [];
    for (let r = 0; r < height; r++) {
        board[r] = [];
        for (let c = 0; c < width; c++) {
            board[r][c] = new Square();
        }
    }
    return board;
}

Lớp GameManager

Quản lý toàn bộ logic của trò chơi.

class GameManager {
    private canvas: HTMLCanvasElement;
    private context: CanvasRenderingContext2D;
    private squares: Square[][] = [];
    private mineCount: number = 0;
    private flaggedCount: number = 0;
    private openedCount: number = 0;
    private observer: GameObserver;

    constructor(canvas: HTMLCanvasElement, observer: GameObserver) {
        this.canvas = canvas;
        this.context = canvas.getContext('2d')!;
        this.observer = observer;
        this.adjustSquareSize();
    }

    private adjustSquareSize() {
        // Tính toán kích thước ô dựa trên kích thước canvas và cài đặt
        SQUARE_STYLE.width = this.canvas.width / GAME_SETTINGS.width;
        SQUARE_STYLE.height = this.canvas.height / GAME_SETTINGS.height;
        SQUARE_STYLE.fontSize = SQUARE_STYLE.width * 0.6; // Tỷ lệ kích thước font
    }

    public startGame() {
        GAME_STATE.isGameOver = false;
        this.mineCount = 0;
        this.flaggedCount = 0;
        this.openedCount = 0;
        this.observer.onStart();

        this.squares = createBoard(GAME_SETTINGS.width, GAME_SETTINGS.height);
        this.adjustSquareSize(); // Đảm bảo kích thước ô phù hợp

        // Đếm số mìn và vẽ bàn cờ ban đầu
        for (let r = 0; r < GAME_SETTINGS.height; r++) {
            for (let c = 0; c < GAME_SETTINGS.width; c++) {
                if (this.squares[r][c].isMine) {
                    this.mineCount++;
                }
                drawSquare(this.squares[r][c], c, r);
            }
        }

        // Đảm bảo có ít nhất một quả mìn
        if (this.mineCount === 0) {
            this.startGame(); // Khởi tạo lại nếu không có mìn nào
        }
    }

    public endGame(isWin: boolean) {
        GAME_STATE.isGameOver = true;
        if (isWin) {
            this.observer.onSuccess();
        } else {
            this.observer.onFail();
        }
        this.observer.onFinish();
    }

    public openSquare(col: number, row: number) {
        if (GAME_STATE.isGameOver || col < 0 || row < 0 || col >= GAME_SETTINGS.width || row >= GAME_SETTINGS.height) {
            return;
        }

        const square = this.squares[row][col];
        if (square.type !== SquareType.CLOSED) {
            return; // Chỉ mở ô chưa mở
        }

        this.openedCount++;
        const isMineHit = !square.open(); // Mở ô, kiểm tra xem có trúng mìn không
        const adjacentMines = this.countAdjacentMines(col, row);

        drawSquare(square, col, row, adjacentMines);

        if (isMineHit) {
            if (adjacentMines === 0) {
                // Mở đệ quy các ô xung quanh nếu không có mìn
                this.revealAdjacent(col, row);
            }
        } else {
            // Trúng mìn, kết thúc trò chơi
            this.endGame(false);
            this.revealAllMines(); // Hiển thị tất cả mìn
        }

        this.checkWinCondition();
    }

    public flagSquare(col: number, row: number) {
        if (GAME_STATE.isGameOver || col < 0 || row < 0 || col >= GAME_SETTINGS.width || row >= GAME_SETTINGS.height) {
            return;
        }

        const square = this.squares[row][col];
        if (square.type === SquareType.CLOSED) {
            square.toggleFlag();
            this.flaggedCount++;
            drawSquare(square, col, row);
        } else if (square.type === SquareType.FLAGGED) {
            square.toggleFlag();
            this.flaggedCount--;
            drawSquare(square, col, row);
        }
        this.checkWinCondition();
    }

    private countAdjacentMines(col: number, row: number): number {
        let count = 0;
        for (let r = Math.max(0, row - 1); r <= Math.min(GAME_SETTINGS.height - 1, row + 1); r++) {
            for (let c = Math.max(0, col - 1); c <= Math.min(GAME_SETTINGS.width - 1, col + 1); c++) {
                if (r === row && c === col) continue;
                if (this.squares[r][c].isMine) {
                    count++;
                }
            }
        }
        return count;
    }

    private revealAdjacent(col: number, row: number) {
        // Mở các ô lân cận
        for (let r = Math.max(0, row - 1); r <= Math.min(GAME_SETTINGS.height - 1, row + 1); r++) {
            for (let c = Math.max(0, col - 1); c <= Math.min(GAME_SETTINGS.width - 1, col + 1); c++) {
                if (r === row && c === col) continue;
                 if (this.squares[r][c].type === SquareType.CLOSED) {
                     this.openSquare(c, r); // Gọi đệ quy
                 }
            }
        }
    }

     private revealAllMines() {
        for (let r = 0; r < GAME_SETTINGS.height; r++) {
            for (let c = 0; c < GAME_SETTINGS.width; c++) {
                 if (this.squares[r][c].isMine && this.squares[r][c].type !== SquareType.FLAGGED) {
                    drawSquare(this.squares[r][c], c, r); // Vẽ lại các quả mìn chưa được đánh dấu
                 }
            }
        }
    }

    private checkWinCondition() {
        // Thắng khi số ô đã mở cộng với số ô đánh dấu đúng bằng tổng số ô,
        // và số ô đánh dấu đúng bằng số lượng mìn.
        if (this.openedCount + this.flaggedCount === GAME_SETTINGS.width * GAME_SETTINGS.height && this.flaggedCount === this.mineCount) {
            this.endGame(true);
        }
    }
}

// Singleton pattern để đảm bảo chỉ có một instance GameManager
let gameManager: GameManager | undefined = undefined;

function getGameInstance(canvas: HTMLCanvasElement, observer: GameObserver): GameManager {
    if (!gameManager) {
        gameManager = new GameManager(canvas, observer);
    }
    return gameManager;
}

// Observer class
class GameObserver {
    private onStart: () => void;
    private onFail: () => void;
    private onSuccess: () => void;
    private onFinish: () => void;

    constructor(callbacks: { onStart?: () => void, onFail?: () => void, onSuccess?: () => void, onFinish?: () => void }) {
        this.onStart = callbacks.onStart || (() => {});
        this.onFail = callbacks.onFail || (() => {});
        this.onSuccess = callbacks.onSuccess || (() => {});
        this.onFinish = callbacks.onFinish || (() => {});
    }

    onStart() { this.onStart(); }
    onFail() { this.onFail(); }
    onSuccess() { this.onSuccess(); }
    onFinish() { this.onFinish(); }
}

Chuyển đổi độ khó

Dựa trên cấu hình GAME_SETTINGS, ta có thể dễ dàng thay đổi độ khó.

function setDifficulty(level: 'easy' | 'normal' | 'hard') {
    switch (level) {
        case 'easy':
            GAME_SETTINGS.width = 7;
            GAME_SETTINGS.height = 7;
            GAME_SETTINGS.mineProbability = 0.05;
            break;
        case 'normal':
            GAME_SETTINGS.width = 10;
            GAME_SETTINGS.height = 10;
            GAME_SETTINGS.mineProbability = 0.15;
            break;
        case 'hard':
            GAME_SETTINGS.width = 15;
            GAME_SETTINGS.height = 15;
            GAME_SETTINGS.mineProbability = 0.25;
            break;
    }
    // Khởi tạo lại game với cài đặt mới
    gameManager?.startGame();
}

Tổng kết

Việc xây dựng một trò chơi như Minesweeper trên Canvas không chỉ là một bài tập thực hành thú vị mà còn giúp bạn hiểu sâu hơn về cách hoạt động của các API đồ họa trên trình duyệt và cách áp dụng các nguyên tắc lập trình hướng đối tượng. Code mẫu đi kèm cung cấp một nền tảng vững chắc để bạn có thể tùy chỉnh và mở rộng thêm.

Thẻ: JavaScript Canvas HTML5 minesweeper Game Development

Đăng vào ngày 19 tháng 7 lúc 00:45