简介:一套基于QT框架与C++语言实现的经典黑白棋游戏完整源码,专为想深入掌握桌面应用开发的程序员设计,适合具备C++基础、正在学习GUI编程或网络编程的开发者。项目由15个文件组成,压缩包仅1.12MB,内含3个C++源文件、3个头文件、1个Qt Designer生成的UI文件及若干展示图片,覆盖从界面构建、事件响应到核心规则判定的完整链路。游戏逻辑模块独立封装,包含合法落点检测、棋子翻转、计分与胜负判定;界面层通过信号槽机制与逻辑层解耦,便于阅读与二次开发。除人机对战外,还提供网络对战功能,利用QTcpSocket实现实时数据交换,包含服务器端移动验证与客户端状态同步,可据此学习TCP通信、消息序列化及同步机制。项目文件组织清晰,导入QT Creator后即可编译运行,适合作为学习QT进阶、综合算法与网络编程的参考案例。已有350人学习下载。
1. 黑白棋源码包:一个QT项目能教会你的不只是画棋盘
如果你在找一份适合课程设计或QT练手项目的棋类游戏源码,黑白棋往往是比五子棋更聪明的选择:规则简单、局面评估容易建模、AI强度也能做出清晰的分层。这份基于QT的黑白棋代码,覆盖了从棋盘绘制、鼠标交互到Minimax搜索的完整链路,拿来改改就能当课设交差,也可以当作理解搜索算法和人机对战的入门样本。它解决的核心问题是「不知道怎么把规则、界面和AI串起来」,适合刚学完C++基础、想用Qt做点完整东西的开发者,也适合想快速抄作业再按自己思路换皮的人。
2. 棋盘与规则引擎:先把8x8的数据结构和落子判定写对
2.1 棋盘数据结构与初始化
黑白棋的棋盘就是一个8x8的二维数组,黑白棋的规则决定它不需要像象棋那样考虑复杂走法,所以用整数数组是最直观的存法。我习惯用0表示空位、1表示黑子、2表示白子,这样在判断时用3 - player就能直接拿到对手的编号,代码写起来很顺手。
// board.h —— 棋盘核心数据结构 #ifndef REVERSI_BOARD_H #define REVERSI_BOARD_H #include <QVector> class ReversiBoard { public: ReversiBoard() { reset(); } // 0 = 空, 1 = 黑, 2 = 白 int cells[8][8]; int currentPlayer; // 当前轮到谁 int blackCount; int whiteCount; void reset() { memset(cells, 0, sizeof(cells)); cells[3][3] = 2; // 白 cells[3][4] = 1; // 黑 cells[4][3] = 1; // 黑 cells[4][4] = 2; // 白 currentPlayer = 1; // 黑先走 blackCount = 2; whiteCount = 2; } bool isValidMove(int row, int col, int player) const; int countFlipped(int row, int col, int player) const; void makeMove(int row, int col, int player); QVector<QPair<int,int>> getAllValidMoves(int player) const; }; #endif这段代码里有一个关键设计:把“落子是否合法”和“实际执行落子”拆成两个函数,而不是写在一起。因为AI搜索的时候会大量调用合法性判断,如果把翻转逻辑混在里面,每次判断都要复制棋盘、执行翻转、再撤销,性能会浪费很多。currentPlayer的存在则是为了界面层方便判断“现在该谁操作”,AI内部也会用它来区分搜索轮次。
2.2 合法落子判断:8个方向扫描的边界处理
黑白棋的合法落子规则一句话就能讲清楚:落子位置必须为空,且至少在一个方向上能夹住至少一枚对方棋子。但“至少一个方向”这个条件,恰恰是新手最容易写漏的。很多人只检测了四个对角线方向,忘了水平和垂直,或者写边界判断时数组越界。
// board.cpp —— 合法落子判断 int ReversiBoard::countFlipped(int row, int col, int player) const { if (cells[row][col] != 0) return 0; int opponent = 3 - player; int total = 0; const int dir[8][2] = { {-1,-1}, {-1,0}, {-1,1}, {0,-1}, {0,1}, {1,-1}, {1,0}, {1,1} }; for (int d = 0; d < 8; ++d) { int r = row + dir[d][0]; int c = col + dir[d][1]; int found = 0; // 沿着当前方向一路走,只统计连续的同色对手子 while (r >= 0 && r < 8 && c >= 0 && c < 8 && cells[r][c] == opponent) { r += dir[d][0]; c += dir[d][1]; ++found; } // 走出循环后必须是己方棋子,且中途至少夹到一枚 if (found > 0 && r >= 0 && r < 8 && c >= 0 && c < 8 && cells[r][c] == player) { total += found; } } return total; } bool ReversiBoard::isValidMove(int row, int col, int player) const { return countFlipped(row, col, player) > 0; }这段逻辑的核心是while循环的终止条件:必须同时满足“下标在棋盘内”和“当前位置是对方棋子”才继续走。换句话说,只要遇到空格、己方棋子或者棋盘边界,循环就停。退出循环后再判断一次当前位置是不是己方棋子,同时检查found > 0,两个条件缺一不可。很多翻车案例就是少了found > 0这个条件,导致落子到空位旁边直接合法,棋局开局一片混乱。
2.3 翻子逻辑:方向独立的翻转函数
翻子逻辑和countFlipped几乎同构,唯一的区别是一个只数不翻,一个边数边翻。很多人图省事,在翻子函数里又写一遍扫描逻辑,其实完全可以在countFlipped的基础上改——但要注意:翻转时不能直接改cells,否则会影响后续方向的判断。
// board.cpp —— 执行落子并翻转 void ReversiBoard::makeMove(int row, int col, int player) { if (cells[row][col] != 0) return; int opponent = 3 - player; const int dir[8][2] = { {-1,-1}, {-1,0}, {-1,1}, {0,-1}, {0,1}, {1,-1}, {1,0}, {1,1} }; // 第一遍:先统计每个方向要翻多少,存到临时数组 int flipCounts[8]; for (int d = 0; d < 8; ++d) { int r = row + dir[d][0]; int c = col + dir[d][1]; int found = 0; while (r >= 0 && r < 8 && c >= 0 && c < 8 && cells[r][c] == opponent) { r += dir[d][0]; c += dir[d][1]; ++found; } if (found > 0 && r >= 0 && r < 8 && c >= 0 && c < 8 && cells[r][c] == player) { flipCounts[d] = found; } else { flipCounts[d] = 0; } } // 第二遍:按方向逐个翻转 cells[row][col] = player; for (int d = 0; d < 8; ++d) { for (int i = 1; i <= flipCounts[d]; ++i) { int r = row + dir[d][0] * i; int c = col + dir[d][1] * i; cells[r][c] = player; } } // 更新双方棋子数 int flipped = 0; for (int i = 0; i < 8; ++i) flipped += flipCounts[i]; if (player == 1) { blackCount += flipped + 1; whiteCount -= flipped; } else { whiteCount += flipped + 1; blackCount -= flipped; } }这里踩过的坑是:如果在第一遍扫描的同时就翻转,同一方向的棋子会被重复计数,因为cells已经被修改了。所以正确顺序是先扫描全部八个方向得出flipCounts数组,再统一执行翻转。另一个容易忽略的细节是更新棋子数时要记得加上刚落下的那一枚,flipped + 1右边的+1就是这个作用。参数上row、col的取值范围要由调用方保证在0~7,函数内部不做过多的边界校验,因为搜索循环里每层都会调用几十上百次,校验过多会影响性能。
3. 人机对战AI:Minimax + Alpha-Beta剪枝的落地
3.1 评估函数:棋子数只是最基础的维度
AI要能下棋,首先得知道“哪个局面更好”。黑白棋最简单的评估就是数棋子,但只数棋子会养出一个只顾眼前翻转的“莽夫”——它不会考虑边角的价值,也不懂行动力的重要性。实战下来,我一般用四个维度加权求和:棋子数、位置权重、行动力、稳定子数量。
// ai_evaluator.cpp —— 棋盘静态评估函数 int evaluateBoard(const ReversiBoard& board, int aiPlayer) { int opponent = 3 - aiPlayer; // 位置权重表:四个角最高,角旁边和最外圈次之,中间偏低 static const int weight[8][8] = { { 100, -20, 10, 5, 5, 10, -20, 100}, { -20, -50, -2, -2, -2, -2, -50, -20}, { 10, -2, 1, 1, 1, 1, -2, 10}, { 5, -2, 1, 0, 0, 1, -2, 5}, { 5, -2, 1, 0, 0, 1, -2, 5}, { 10, -2, 1, 1, 1, 1, -2, 10}, { -20, -50, -2, -2, -2, -2, -50, -20}, { 100, -20, 10, 5, 5, 10, -20, 100} }; int score = 0; for (int r = 0; r < 8; ++r) { for (int c = 0; c < 8; ++c) { if (board.cells[r][c] == aiPlayer) { score += weight[r][c] + 10; // 基础子数分 } else if (board.cells[r][c] == opponent) { score -= weight[r][c] + 10; } } } // 行动力加权:能走的步数越多越好 int aiMoves = board.getAllValidMoves(aiPlayer).size(); int oppMoves = board.getAllValidMoves(opponent).size(); score += (aiMoves - oppMoves) * 15; return score; }位置权重表是这套评估函数的灵魂:四个角给100分,因为角一旦占住就不可能被翻回去;角的斜对角给-50,因为下在那里几乎等于送对手一个角。这套权重不是最优的,但作为入门已经完全够用。行动力加权乘15是基于经验,如果你发现AI太保守、总是不敢翻对面的子,可以把系数调低到5~10;如果AI老是走一些“牺牲当前、换取未来”的棋,说明权重太高了。权重表的调参是黑白棋AI里最玄学的部分,没有标准答案,得靠大量对弈日志来校准。
3.2 搜索算法:Minimax + Alpha-Beta剪枝
评估函数只能看一步,要让AI有“前瞻性”,就得做搜索。黑白棋的分支因子通常比象棋小很多,平均每个局面大概10~20个合法落子,所以搜索深度做到4~6层就能有不错的表现。基础框架是递归的Minimax:我方节点取最大值,对方节点取最小值,交替向下搜索。
// ai_search.cpp —— Alpha-Beta剪枝搜索 int alphaBeta(ReversiBoard& board, int depth, int alpha, int beta, int currentPlayer, int aiPlayer) { QVector<QPair<int,int>> moves = board.getAllValidMoves(currentPlayer); // 双方都无棋可下,说明对局结束,直接返回最终评估 if (moves.isEmpty() && board.getAllValidMoves(3 - currentPlayer).isEmpty()) { int finalScore = board.blackCount - board.whiteCount; return (aiPlayer == 1) ? finalScore * 1000 : -finalScore * 1000; } // 当前玩家无棋可下,跳过回合 if (moves.isEmpty()) { return alphaBeta(board, depth - 1, alpha, beta, 3 - currentPlayer, aiPlayer); } if (depth <= 0) { return evaluateBoard(board, aiPlayer); } if (currentPlayer == aiPlayer) { int best = -999999; for (const auto& mv : moves) { ReversiBoard copy = board; copy.makeMove(mv.first, mv.second, currentPlayer); int val = alphaBeta(copy, depth - 1, alpha, beta, 3 - currentPlayer, aiPlayer); best = qMax(best, val); alpha = qMax(alpha, best); if (beta <= alpha) break; // 剪枝 } return best; } else { int best = 999999; for (const auto& mv : moves) { ReversiBoard copy = board; copy.makeMove(mv.first, mv.second, currentPlayer); int val = alphaBeta(copy, depth - 1, alpha, beta, 3 - currentPlayer, aiPlayer); best = qMin(best, val); beta = qMin(beta, best); if (beta <= alpha) break; // 剪枝 } return best; } }注意这里对“终局”和“无棋可下”的处理:如果双方都没有合法落子,游戏结束,直接用棋子差乘1000返回,这样AI会为了赢棋而赢棋,而不是只追求评估函数的最大化。如果只是当前玩家无子可下,则跳过回合继续搜索。剪枝的两个参数alpha和beta分别记录当前路径上我方已经拿到的最低保障分和对方能接受的最高上限,一旦beta <= alpha,说明这个分支已经不可能改变最终决策,直接砍掉。深度建议从4开始试,如果觉得AI反应慢,优先优化评估函数而不是加大深度。
3.3 难度分级:一张表解决三个档位
完整的人机对战菜单一般做三个难度:简单、中等、困难。这个分级的实现并不玄学,核心就两个参数:搜索深度和评估权重。
| 难度 | 搜索深度 | AI行为特征 |
|---|---|---|
| 简单 | 1~2 | 只看眼前几步,会主动让出边角,适合新手练手 |
| 中等 | 3~4 | 能守住边角,但在中局复杂交换时容易失误 |
| 困难 | 5~6 | 配合位置权重和行动力评估,能打出压制性局面 |
// ai_worker.cpp —— 根据难度选择AI参数 AiResult decideMove(const ReversiBoard& board, int aiPlayer, int difficulty) { QVector<QPair<int,int>> moves = board.getAllValidMoves(aiPlayer); if (moves.isEmpty()) return {-1, -1}; int depth = (difficulty == 0) ? 1 : (difficulty == 1) ? 3 : 5; int bestScore = -999999; QPair<int,int> bestMove = moves.first(); for (const auto& mv : moves) { ReversiBoard copy = board; copy.makeMove(mv.first, mv.second, aiPlayer); int score = alphaBeta(copy, depth - 1, -999999, 999999, 3 - aiPlayer, aiPlayer); if (score > bestScore) { bestScore = score; bestMove = mv; } } return {bestMove.first, bestMove.second}; }这里有一个细节:alphaBeta在递归时会创建大量的棋盘副本(ReversiBoard copy = board),如果ReversiBoard里用了动态分配的内存或者复杂的容器,性能会很难看。解决方案有两种:一是让ReversiBoard保持纯内置数组的拷贝构造,二是改成边下边搜索边撤销的“就地回溯”写法。前者简单直白,后者性能好但容易写错,新手建议先跑通前者再优化。
4. QT界面:绘制、事件与多线程配合
4.1 QPainter棋盘绘制:把逻辑棋盘变成看得见的网格
QT界面部分的核心是用QPainter在QWidget::paintEvent里绘制。黑白棋的棋盘只需要画一个8x8的网格、在交叉点上画圆形棋子,看起来简单,但坐标换算不少人第一版会搞错方向——把row当成x、col当成y,导致棋子位置镜像翻转。
// gamewidget.cpp —— 棋盘绘制核心代码 void GameWidget::paintEvent(QPaintEvent*) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing); int side = qMin(width(), height()) / 9; // 每个格子边长 int offsetX = (width() - side * 8) / 2; int offsetY = (height() - side * 8) / 2; // Step 1: 绘制绿色棋盘背景 painter.fillRect(offsetX - 4, offsetY - 4, side * 8 + 8, side * 8 + 8, QColor(0, 128, 0)); // Step 2: 画网格线 painter.setPen(QPen(QColor(60, 60, 60), 1)); for (int i = 0; i <= 8; ++i) { painter.drawLine(offsetX + i * side, offsetY, offsetX + i * side, offsetY + side * 8); painter.drawLine(offsetX, offsetY + i * side, offsetX + side * 8, offsetY + i * side); } // Step 3: 画棋子 for (int row = 0; row < 8; ++row) { for (int col = 0; col < 8; ++col) { if (board.cells[row][col] == 0) continue; int cx = offsetX + col * side + side / 2; int cy = offsetY + row * side + side / 2; painter.setBrush((board.cells[row][col] == 1) ? QColor(30, 30, 30) : QColor(240, 240, 240)); painter.drawEllipse(QPoint(cx, cy), side * 0.38, side * 0.38); } } }坐标换算的规律是:行号row对应y轴,列号col对应x轴。offsetX和offsetY负责让棋盘在窗口里水平垂直居中,side取窗口宽高较小值的九分之一,这样四周能留出边距。棋子的半径设为side * 0.38,留出一点空隙避免相邻棋子连在一起影响观感。每次落子后只需要调用update()触发重绘,不用手动操作界面的每个细节。
4.2 鼠标事件与信号槽:点击坐标映射到棋盘位置
鼠标点击映射听起来简单,但有个隐蔽的坑:如果窗口可以被缩放,点击事件里的坐标和绘制时的坐标必须用同一套换算公式。最稳妥的做法是提取一个getBoardPos(QPoint pos)函数,把坐标转换逻辑收敛到一处。
// gamewidget.cpp —— 鼠标点击落子与信号槽 void GameWidget::mousePressEvent(QMouseEvent* event) { if (gameOver || isAiThinking) return; int side = qMin(width(), height()) / 9; int offsetX = (width() - side * 8) / 2; int offsetY = (height() - side * 8) / 2; int col = (event->pos().x() - offsetX) / side; int row = (event->pos().y() - offsetY) / side; // 点击到棋盘外直接忽略 if (row < 0 || row >= 8 || col < 0 || col >= 8) return; if (!board.isValidMove(row, col, board.currentPlayer)) return; board.makeMove(row, col, board.currentPlayer); update(); emit moveMade(row, col); // 落子后自动切换到AI回合 if (board.currentPlayer == aiPlayer) { startAiThink(); } }事件处理的顺序很重要:先判断是否允许操作(gameOver、isAiThinking),再判断坐标是否在棋盘内,最后判断落子是否合法。注意emit moveMade是给外部监听用的,比如状态栏显示步数、记录对弈日志。如果把这些逻辑全都塞进mousePressEvent里,界面类会越来越臃肿,所以用信号把“棋局变化”和“界面反馈”解耦。
4.3 AI线程与状态刷新:别让搜索卡死界面
Alpha-Beta搜索在深度5时通常需要几百毫秒到几秒,如果直接放在鼠标事件的调用栈里执行,整个窗口会冻结,拖动窗口时画面撕裂,体验非常糟糕。正确做法是用QThread把AI计算放到后台线程,算完再通过信号把结果传回主线程。
// aithread.h —— AI计算线程 class AiThread : public QThread { Q_OBJECT public: AiThread() = default; void run() override { auto result = decideMove(board, aiPlayer, difficulty); emit resultReady(result.first, result.second); } ReversiBoard board; int aiPlayer = 2; int difficulty = 1; signals: void resultReady(int row, int col); };// gamewidget.cpp —— 启动AI与接收结果 void GameWidget::startAiThink() { isAiThinking = true; setCursor(Qt::WaitCursor); AiThread* thread = new AiThread(this); thread->board = board; // 拷贝当前局面 thread->aiPlayer = aiPlayer; thread->difficulty = difficulty; connect(thread, &AiThread::resultReady, this, [this, thread](int row, int col) { if (row >= 0 && col >= 0) { board.makeMove(row, col, aiPlayer); update(); } isAiThinking = false; unsetCursor(); thread->deleteLater(); // 落子后检查游戏是否结束 checkGameOver(); }); thread->start(); }这里有个容易被忽略的点:thread->board = board是在主线程里做的一次完整拷贝。如果你的ReversiBoard拷贝构造写得不干净,比如持有指针、共享了外部数组,那AI线程里搜索时就会出现数据竞争。经验做法是让ReversiBoard只包含一个普通的int[8][8]数组和几个整数,这样默认拷贝就是深拷贝,线程安全无忧。AI线程的释放用deleteLater而不是直接delete,因为在信号槽回调里删除发送者对象有风险。
5. 避坑:黑白棋源码复现中常见的五个翻车点
5.1 点击无响应:合法落子判断漏了方向
现象:棋盘能正常显示,但点击某些明显可以落子的位置没有反应。
原因:isValidMove里的方向数组少写了水平或垂直方向。比如只定义了{-1,-1},{-1,0},{-1,1}三个方向,导致从上方夹子的情况永远检测不到。
解决:把方向数组写成const int dir[8][2],强制自己数一遍确实是8组。如果还是不放心,写一个测试用例:开局时黑方应该能下在[2][3]、[3][2]、[5][4]、[4][5]四个位置,把这几个坐标在调试器里逐个检查countFlipped的返回值。
提示:这类逻辑错误单靠肉眼很难发现,建议在
reset()之后加一个assert断言来验证初始合法落子数量。
5.2 界面假死:搜索任务堵住了主线程
现象:点击“AI先手”后,窗口立刻变白、拖不动,过几秒才恢复。
原因:alphaBeta函数直接写在鼠标事件处理流程里,递归深度一上来,主线程的事件循环被阻塞,重绘、拖动、按钮点击全部排队。
解决:见前面AiThread的写法。如果不想引入QThread,退而求其次可以在搜索函数里每隔几层 recursion 调用QCoreApplication::processEvents(),但这只是治标,官方不推荐在计算密集型循环里反复刷事件。最省心的方案是QtConcurrent::run + QFutureWatcher,代码量更少。
5.3 翻子数量不对:翻转途中木已成舟
现象:中局时落子后棋盘上的棋子数量总是算不对,黑子白子数量加起来不是64。
原因:makeMove里边扫描方向边翻转,第一个方向翻完,第二个方向扫描时把己方翻过的棋子当成对手棋子继续“夹”,导致重复计数。
解决:严格按照“先扫描统计、后统一翻转”的两阶段写法。另一个隐蔽点:翻转循环里i从1开始而不是从0开始,从0开始会把落子点本身也循环进去,覆盖掉刚放下的棋子。
5.4 终局判定提前:忘掉对手也是“无棋可下”
现象:游戏进行到中盘,AI突然宣布对局结束,明明棋盘还有大片空位。
原因:只判断了当前玩家的合法落子为空就判定游戏结束。黑白棋的正确终局条件是双方都无合法落子,因为黑白棋允许在无子可下时“过手”。
解决:在alphaBeta和界面层的checkGameOver都改成双重判断。参考搜索代码里的写法,先判断当前玩家,再判断对手,两边都空才是终局。如果你只改界面不改AI,就会出现AI还在搜索、界面已经弹窗结束的诡异状态。
5.5 Alpha-Beta剪枝写错导致AI越下越差
现象:AI开局还会占角,中局突然开始走废棋,主动把边角送给玩家。
原因:剪枝条件写反了。常见错误是把beta <= alpha写成了beta < alpha,或者把返回值的best、alpha更新顺序弄反,导致搜索树被错误裁剪,AI反而选不到最优解。
解决:先关掉剪枝(只保留Minimax),确认AI的棋力是随深度递增的,再逐步打开剪枝。如果打开后棋力反而下降,把beta <= alpha的两处判断打印出来对照标准实现。另外一个排查技巧:给AI做一场“左右互搏”,同一深度下对比有无剪枝的落子是否一致,正常情况下完全一致才说明剪枝正确。
6. 最后一个技巧:用对弈日志验证AI棋力,而不是靠感觉
调AI的时候最怕“感觉变强了”,一上强度又露馅。我给这份源码配了一个简单的对弈日志模块,每次落子都把棋盘状态、搜索深度、评估分数和落子位置写进CSV,AI每次决策后追加一行。复盘时用Python脚本统计不同阶段的平均分差,就能量化出权重调整到底有没有效果。
最常见的一个度量是“角占有率”:AI在对弈结束时占的角数占总角数的比例。这个指标对黑白棋来说基本等价于胜率,所以如果你在调位置权重表,对比两版AI各自对战同一对手的角占有率,比看总胜负更直观。另一个好用的验证手段是制造固定残局——从某局中盘抽出局面,让新旧两版AI分别以该局面对战,观察谁能赢下这个局面。
我自己的习惯是:每次改完评估函数,先让修改后的AI和上一版AI连下20局,记录角占有率和平均负差。如果新参数在角占有率上跑不过旧版,立刻回滚,不纠缠“理论上应该更好”。从那以后我调整黑白棋AI权重时都强制走一遍这个流程,宁可多花十分钟跑对弈,也不靠眼神判断棋力。希望这套方法和这份源码能帮你在QT黑白棋的路上少走几步弯路,做出自己满意的版本。
本文还有配套的精品资源,点击获取