Qt小游戏开发:扫雷

继续把Qt小游戏写一下~


预览

Qt小游戏开发:扫雷_第1张图片

步骤

Qt小游戏开发:扫雷_第2张图片
整体的代码结构,游戏逻辑类和游戏界面类分离,采用MVC的思想。

1 定义游戏数据结构
游戏地图实际上是由一个个方块组成的二维矩阵,每个方块存储数字、雷或者标记的情况,另外还要定义一些游戏的运行状态枚举
// ---------------- 全局定义变量 ---------------- //
// 方块的状态,未挖掘,翻开,标记,雷出现,错误标记
enum BlockState
{
    UN_DIG,
    DIGGED,
    MARKED,
    BOMB,
    WRONG_BOMB
};
// 雷方块类
struct MineBlock
{
    BlockState curState; // 当前状态
    int valueFlag; // 数值,0到8, -1表示雷
};

// 游戏状态,分为未完,有错误标记、输、赢四种
enum GameState
{
    PLAYING,
    FAULT,
    OVER,
    WIN
};

// 游戏难度,有低级、中级、高级
enum GameLevel
{
    BASIC,
    MEDIUM,
    HARD
};

// 游戏默认参数
const int kRow = 15;
const int kCol = 20;
const int kMineCount = 50;
const int kTime = 0;
// ----------------------------------------------- //

2 创建游戏逻辑类
一个游戏逻辑类包括地图,游戏状态等,以及一些控制函数
class GameModel
{
public:
    GameModel();
    virtual ~GameModel();
public:
    void digMine(int m, int n); //挖雷, m是行, n是列
    void markMine(int m, int n); // 标记雷
    void createGame(int row = kRow, int col = kCol, int mineCount = kMineCount, GameLevel level = MEDIUM); // 初始化游戏
    void restartGame();  // 按当前参数重新开始游戏
    void checkGame(); // 检查游戏输赢
public:
    std::vector<std::vector<MineBlock>> gameMap; // 游戏地图
    int mRow; // 地图行数
    int mCol; // 地图列数
    int totalMineNumber; // 雷数
    int curMineNumber;   // 当前雷数(仅用于显示)
    int timerSeconds; // 计时(秒)


    GameState gameState; // 当前游戏状态
    GameLevel gameLevel; // 当前游戏难度
};

3 游戏逻辑
(1)游戏初始化
void GameModel::createGame(int row, int col, int mineCount, GameLevel level)
{
    // 先清空已经有的游戏地图
    gameMap.clear();
    // 设置成员变量
    mRow = row;
    mCol = col;
    totalMineNumber = mineCount;
    curMineNumber = mineCount;
    gameState = PLAYING;
    gameLevel = level;
    timerSeconds = 0;

    // 初始化雷方块
    for(int i = 0; i < mRow; i++)
    {
        //添加每行的block
        std::vector<MineBlock> lineBlocks;
        for(int j = 0; j < mCol; j++)
        {
            MineBlock mineBlock;
            mineBlock.curState = UN_DIG; // 默认都是未挖掘
            mineBlock.valueFlag = 0; // 默认都是0
            lineBlocks.push_back(mineBlock);
        }
        gameMap.push_back(lineBlocks);
    }

    // 随机布雷
    srand((unsigned int)time(0));
    int k = totalMineNumber;
    while(k > 0)
    {
        // 埋雷并防止重叠
        int pRow = rand() % mRow;
        int pCol = rand() % mCol;
        if(gameMap[pRow][pCol].valueFlag != -1)
        {
             gameMap[pRow][pCol].valueFlag = -1;
             k--; // 如果原来就有雷重新循环
        }
    }

    // 计算雷周围的方块数字
    for(int i = 0; i < mRow; i++)
    {
        for(int j = 0; j < mCol; j++)
        {
            // 周围八个方块(排除自己,在地图范围内)的数字根据雷的数目叠加
            // y为行偏移量,x为列偏移量
            // 前提条件是本方块不是雷
            if(gameMap[i][j].valueFlag != -1)
            {
                for(int y = -1; y <= 1; y++)
                {
                    for(int x = -1; x <= 1; x++)
                    {
                        if(i + y >= 0
                        && i + y < mRow
                        && j + x >= 0
                        && j + x < mCol
                        && gameMap[i + y][j + x].valueFlag == -1
                        && !(x == 0 && y == 0))
                        {
                            // 方块数字加1
                            gameMap[i][j].valueFlag++;
                        }
                    }
                }
            }
        }
    }
}
  • 随机布雷
  • 计算方块数字
(2)挖雷
void GameModel::digMine(int m, int n)
{
    // 正常方块且没有被翻开过,标记为已挖
    if(gameMap[m][n].valueFlag > 0
     && gameMap[m][n].curState == UN_DIG)
    {
        gameMap[m][n].curState = DIGGED;
    }

    // 遇到空白块(数字0)就递归挖雷,如果踩雷就爆掉,游戏结束
    if(gameMap[m][n].valueFlag == 0
     && gameMap[m][n].curState == UN_DIG)
    {
        gameMap[m][n].curState = DIGGED;
        for(int y = -1; y <= 1; y++)
        {
            for(int x = -1; x <= 1; x++)
            {
                if(m + y >= 0
                && m + y < mRow
                && n + x >= 0
                && n + x < mCol
                && !(x == 0 && y == 0))
                {
                    digMine(m + y, n + x);
                }
            }
        }
    }

    // 踩雷了
    if(gameMap[m][n].valueFlag == -1)
    {
        gameState = OVER;
        gameMap[m][n].curState = BOMB;
    }

    // 检查游戏输赢,并作调整
    checkGame();
}
  • 递归挖雷
  • 挖到雷游戏结束
(3)标记方块
void GameModel::markMine(int m, int n)
{
    // 如果标记错了,就记为错误标记,在ui层游戏结束时做绘制区分
    // 注意这里有个逻辑,如果一个方块标记两次会回到未挖掘的状态
    if(gameMap[m][n].curState == UN_DIG)
    {
        if(gameMap[m][n].valueFlag == -1)
        {
            gameMap[m][n].curState = MARKED;
        }
        else
        {
            gameState = FAULT;
            gameMap[m][n].curState = WRONG_BOMB;
        }
        curMineNumber--; // 挖对了雷就减1
    }
    else if(gameMap[m][n].curState == MARKED || gameMap[m][n].curState == WRONG_BOMB)
    {
        gameMap[m][n].curState = UN_DIG;
        gameState = PLAYING;
        curMineNumber++; // 雷数加回来
    }
    // 检查游戏输赢,并作调整
    checkGame();
}
  • 标记雷,并且当前显示雷数跟着减
  • 标记错误了会把游戏状态设置成FAULT
  • 如果挖完了且挖对了游戏就赢了
(4)检查游戏状态
void GameModel::checkGame()
{
    // 游戏结束,显示所有雷
    if(gameState == OVER)
    {
        // 输了就显示所有的雷以及标错的雷
        for(int i = 0; i < mRow; i++)
        {
            for(int j = 0; j < mCol; j++)
            {
                if(gameMap[i][j].valueFlag == -1)
                {
                    gameMap[i][j].curState = BOMB;
                }
            }
        }
        return;
    }
    // 如果雷排完了,且所有方块都挖出或者标记
    if(gameState != FAULT)
    {
        for(int i = 0; i < mRow; i++)
        {
            for(int j = 0; j < mCol; j++)
            {
                if(gameMap[i][j].curState == UN_DIG)
                {
                    gameState = PLAYING;
                    return;
                }
            }
        }
        // 否则既没有错误标记游戏状态又不是输或者进行中,游戏就是赢了
        gameState = WIN;
    }
}
  • 这个函数每次在挖雷和标记雷时都要调用
  • 每次检查都要更新游戏状态
4 游戏界面类
游戏界面其实就是在window里面不断重绘,并且设置鼠标点击监听
class MainGameWindow : public QMainWindow
{
    Q_OBJECT

public:
    explicit MainGameWindow(QWidget *parent = 0);
    ~MainGameWindow();
protected:
    virtual void paintEvent(QPaintEvent *event);       // 界面重绘
    virtual void mousePressEvent(QMouseEvent *event);  // 鼠标控制
private:
    Ui::MainGameWindow *ui;

    GameModel *game; // 游戏
    QTimer *timer;     // 计时器
    QLabel *timeLabel; // 计时数字

    void handleGameState(GameModel *game); // 处理游戏状态
private slots:
    void onStartGameClicked();    // 开始游戏
    void onLevelChooseClicked();  // 选择游戏难度
    void onQuitClicked();         // 退出游戏
    void updateTimer();           // 计时
};

5 游戏界面控制
(1)启动时设置一些元素并初始化游戏模型
MainGameWindow::MainGameWindow(QWidget *parent) :
    QMainWindow(parent),
    ui(new Ui::MainGameWindow)
{
    ui->setupUi(this);
    // 创建计时数字标签
    timeLabel = new QLabel(this);
    // 关联信号槽
    connect(ui->actionStart, SIGNAL(triggered(bool)), this, SLOT(onStartGameClicked()));
    connect(ui->actionBasic, SIGNAL(triggered(bool)), this, SLOT(onLevelChooseClicked()));
    connect(ui->actionMedium, SIGNAL(triggered(bool)), this, SLOT(onLevelChooseClicked()));
    connect(ui->actionHard, SIGNAL(triggered(bool)), this, SLOT(onLevelChooseClicked()));
    connect(ui->actionQuit, SIGNAL(triggered(bool)), this, SLOT(onQuitClicked()));

    timer = new QTimer(this);
    connect(timer, SIGNAL(timeout()), this, SLOT(updateTimer()));


    // 创建游戏初始化游戏,设置好参数,默认是中级,启动计时器
    // 定义窗口大小(必须放在游戏创建之后后面,该函数设置后大小不可变动,窗口强制重绘)
    game = new GameModel;
    game->createGame();
    setFixedSize(game->mCol * blockSize + offsetX * 2, game->mRow * blockSize + offsetY * 2 + spaceY);
    timeLabel->setGeometry(game->mCol * blockSize + offsetX * 2 - 80, spaceY / 2, 80, 20);
    timeLabel->setText("Time: " + QString::number(game->timerSeconds) + " s");
    timer->start(1000);
}
这里面关联了一些button的的信号槽,初始化了界面和游戏模型,设置一个定时器用于游戏计时。

(2)窗口重绘
void MainGameWindow::paintEvent(QPaintEvent *event)
{
    QPainter painter(this);
    QPixmap bmpBlocks(":/res/blocks.bmp");
    QPixmap bmpFaces(":/res/faces.bmp");
    QPixmap bmpFrame(":/res/frame.bmp");
    QPixmap bmpNumber(":/res/timenumber.bmp");

    // 绘制笑脸
    switch(game->gameState)
    {
    case OVER:
        painter.drawPixmap((game->mCol * blockSize + offsetX * 2) / 2 - 12, spaceY / 2, bmpFaces, 0 * 24, 0, 24, 24); // 24是笑脸的边长,锚点在左上,因为工具栏占了些,所以看起来不再中间
        break;
    case PLAYING:
        painter.drawPixmap((game->mCol * blockSize + offsetX * 2) / 2 - 12, spaceY / 2, bmpFaces, 1 * 24, 0, 24, 24);
        break;
    case WIN:
        painter.drawPixmap((game->mCol * blockSize + offsetX * 2) / 2 - 12, spaceY / 2, bmpFaces, 2 * 24, 0, 24, 24);
        break;
    default:
        painter.drawPixmap((game->mCol * blockSize + offsetX * 2) / 2 - 12, spaceY / 2, bmpFaces, 1 * 24, 0, 24, 24);
        break;
    }

    // 绘制剩余雷数
    int n = game->curMineNumber;
    int posX = (game->mCol * blockSize + offsetX * 2) / 2 - 50; // 最后一位数字的横坐标
    if(n <= 0) // 如果雷数为0或者减到0以下,单独绘制
    {
        painter.drawPixmap(posX, spaceY / 2, bmpNumber, n * 20, 0, 20, 28); // 20是数字的宽,28是高
    }
    while(n > 0) // 如果是多位数
    {
        painter.drawPixmap(posX - 20, spaceY / 2, bmpNumber, n % 10 * 20, 0, 20, 28); // 每次从后面绘制一位
        n /= 10;
        posX -= 20;
    }

    // 绘制雷区
    for(int i = 0; i < game->mRow; i++)
    {
        for(int j = 0; j < game->mCol; j++)
        {
            switch(game->gameMap[i][j].curState)
            {
            // 根据不同的方块状态绘制,算出在bmp中的偏移量
            case UN_DIG:
                painter.drawPixmap(j * blockSize + offsetX, i * blockSize + offsetY + spaceY , bmpBlocks, blockSize * 10, 0, blockSize, blockSize);
                break;
            case DIGGED:
                painter.drawPixmap(j * blockSize + offsetX, i * blockSize + offsetY + spaceY, bmpBlocks, blockSize * game->gameMap[i][j].valueFlag, 0, blockSize, blockSize);
                break;
            case MARKED:
                painter.drawPixmap(j * blockSize + offsetX, i * blockSize + offsetY + spaceY, bmpBlocks, blockSize * 11, 0, blockSize, blockSize);
                break;
            case BOMB:
                painter.drawPixmap(j * blockSize + offsetX, i * blockSize + offsetY + spaceY, bmpBlocks, blockSize * 9, 0, blockSize, blockSize);
                break;
            case WRONG_BOMB:
                if(game->gameState == PLAYING || game->gameState == FAULT)
                {
                    // 如果还在游戏中就显示旗子
                    painter.drawPixmap(j * blockSize + offsetX, i * blockSize + offsetY + spaceY, bmpBlocks, blockSize * 11, 0, blockSize, blockSize);
                }
                else if(game->gameState == OVER)
                {
                    // 如果游戏已经结束,就显示标错了
                    painter.drawPixmap(j * blockSize + offsetX, i * blockSize + offsetY + spaceY, bmpBlocks, blockSize * 12, 0, blockSize, blockSize);
                }
                break;
            default:
                break;
            }
        }
    }
    // 处理游戏状态
    handleGameState(game);
}
根据游戏的状态和游戏模型中各方块的情况、计时器的情况,进行针对性的重绘,实现游戏界面更新。
这里面用到了位图偏移量绘制。

(3)鼠标控制
void MainGameWindow::mousePressEvent(QMouseEvent *event)
{
    if(event->y() < spaceY + offsetY)
    {
        int x = event->x();
        int y = event->y();
        // 此时判断是否点击笑脸
        if(x >= (game->mCol * blockSize + offsetX * 2) / 2 - 12
        && x <= (game->mCol * blockSize + offsetX * 2) / 2 + 12
        && y >= spaceY / 2
        && y <= spaceY / 2 + 24)
        {
            game->restartGame(); // 重玩
            timer->start(1000);
            timeLabel->setText("Time: " + QString::number(game->timerSeconds) + " s"); // 每次重玩都将计时显示为0s
            update();
        }
    }
    else if(game->gameState != OVER && game->gameState != WIN)
    {
        // 游戏没输或没赢才接受点击
        // 此时判断点击的是哪个方块
        // 获得点击坐标
        int px = event->x() - offsetX;
        int py = event->y() - offsetY - spaceY;
        // 换算成方格索引
        int row = py / blockSize;
        int col = px / blockSize;
        // 根据不同情况响应
        switch(event->button())
        {
        case Qt::LeftButton:
            game->digMine(row, col);
            update(); // 每次点击都要重绘
            break;
        case Qt::RightButton:
            game->markMine(row, col);
            update();
            break;
        default:
            break;
        }
    }

}
做了简单的碰撞检测,左键挖雷,右键标记。

(4)选择难度
void MainGameWindow::onLevelChooseClicked()
{
    QAction *actionSender = (QAction *)dynamic_cast<QAction *>(sender());
    if(actionSender == ui->actionBasic)
    {
        qDebug() << "basic";
        // 先设置游戏模型
        game->createGame(8, 10, 15, BASIC);
    }
    else if(actionSender == ui->actionMedium)
    {
        qDebug() << "medium";
        game->createGame(15, 20, 50, MEDIUM);
    }
    else if(actionSender == ui->actionHard)
    {
        qDebug() << "hard";
        game->createGame(20, 30, 100, HARD);
    }
    // 重新计时
    timer->start(1000);
    // 再刷新UI,窗口大小改变会强制重绘
    timeLabel->setText("Time: " + QString::number(game->timerSeconds) + " s");
    timeLabel->setGeometry(game->mCol * blockSize + offsetX * 2 - 80, spaceY / 2, 80, 20);
    setFixedSize(game->mCol * blockSize + offsetX * 2, game->mRow * blockSize + offsetY * 2 + spaceY);
}
选择难度里面可以根据信号槽中的信号类型,设置不同的难度,直接对game设置参数,方便快捷。

(5)计时
void MainGameWindow::updateTimer()
{
    // 计时器计时
    game->timerSeconds++;
    timeLabel->setText("Time: " + QString::number(game->timerSeconds) + " s");
    qDebug() << game->timerSeconds;
}
游戏结束可以看到计时成绩

截图

  Qt小游戏开发:扫雷_第3张图片  Qt小游戏开发:扫雷_第4张图片

Qt小游戏开发:扫雷_第5张图片 

源码

csdn: 扫雷
github:  MineSweep


你可能感兴趣的:(qt,游戏开发)