用C++和Qt从零实现黑白棋AI对战:核心算法与界面设计
简介这是一份基于C和Qt框架开发的黑白棋翻转棋项目源码面向正在学习C GUI编程、需要课程设计或想了解棋类游戏实现的开发者。代码用二维数组表示8x8棋盘完整实现了落子合法性判断、水平垂直与对角线方向的翻子逻辑、无法落子时的胜负判定并通过Qt的信号槽与鼠标事件建立起直观的人机/双人对战交互。界面层使用Qt控件绘制棋盘并显示当前玩家、回合数等信息整体结构清晰便于从入口文件一路追踪到核心算法。压缩包共28个文件其中包含7个cpp源码、4个头文件、ui界面描述和pro工程文件同时附有Windows下可直接运行的exe、makefile及debug/release构建产物整体仅1.12MB轻量且完整。当前已有361人学习下载适合对照源码和运行效果理解黑白棋规则与Qt事件循环的配合也可在此基础上继续补充AI搜索或存档读档功能作为进一步拓展的起点。 我最早接触黑白棋Reversi/Othello是大学时在别人的诺基亚手机上玩到停不下来。后来自己也用C写过“五子棋”“贪吃蛇”但真正把“AI对战图形界面”完整做出来反而是用Qt重写黑白棋的时候——因为只有双人对称棋类才会逼着你把局面评估、搜索剪枝、事件响应这些模块串成一个真正能跑的东西。这篇文章就把我当时从零到一实现“黑白棋 C Qt”的过程、踩坑和最终方案完整写出来给你一份可以直接“抄作业”的思路。1. 项目整体设计与思路拆解1.1 为什么选C和Qt来做黑白棋先聊一个大家都会纠结的问题做棋类小游戏Python配pygame不香吗甚至纯JavaScript在浏览器里跑不是更方便我的看法是如果目的只是“跑起来”确实用啥都行但如果你想练C的工程能力、理解事件驱动编程同时做出一个跨平台、外观不廉价的桌面应用Qt几乎是最合适的组合。C负责核心逻辑包括棋盘状态、落子合法性判断、翻转算法、AI搜索评估这些逻辑需要高效且可控的内存管理C天然合适。Qt则负责界面部分它的QPainter绘制机制非常适合做棋盘这种需要频繁重绘的场景而且信号槽Signals Slots机制让“玩家点击棋盘 → 调用逻辑模块 → 更新界面”这条链路变得非常清晰——比到处写回调舒服太多了。另外Qt的跨平台能力也值得一提。我最初在Windows上开发调试后来把整个项目丢到Linux上用Qt Creator直接打开.pro文件就能编译运行几乎没做任何平台相关的改动。这一点在学习和做作品集时都很加分。1.2 黑白棋规则里最容易被忽略的核心点说到规则很多人第一反应是“黑白双方轮流下棋夹住对方棋子就翻色”。但真正动手写代码时你会发现有几个细节必须提前想清楚合法落子点的判定一个位置合法要求从这个位置出发至少在某个方向共8个方向上存在连续的一个或多个对方棋子且末端是你的棋子。更直白说新下的棋子必须能“夹住”至少一枚对方棋子。无合法着法时的处理如果一方当前没有任何合法位置这一方必须跳过回合由对方继续下。这一步是新手最容易漏的——UI阶段玩家发现自己下不了棋实际是程序漏了“pass跳过”逻辑。游戏结束的判定棋盘下满或者双方都无合法着法时游戏结束统计黑白子数量多者胜。这三个点听起来像废话却是黑白棋程序里最容易出Bug的地方。我在第一次写的时候跳过规则结果AI经常“傻掉”——明明能下却一直pass后来才发现是自己写合法判断时漏掉了“必须存在至少一个方向能翻转”这个条件。1.3 技术选型与模块划分整体技术方案如下模块选型说明开发语言C11/14兼容性好Qt 5系列都支持GUI框架Qt 5.15Widgets用QPainter绘制棋盘比QML更容易入门构建工具qmake或CMake单文件用qmake方便后续扩展建议CMakeAI算法极小极大搜索 Alpha-Beta剪枝固定深度4~6层配合静态评估函数我把项目按经典的三层结构划分逻辑核心层Board类管理棋盘状态、落子、翻转、胜负判断、合法位置计算。这一层完全不依赖Qt头文件方便单独测试也方便以后移植成命令行版本或服务器版本。AI引擎层AI类接收一个棋盘状态返回最优落子坐标。引入搜索算法和评估函数。界面层MainWindowBoardWidget负责棋盘绘制、鼠标事件、菜单栏、状态提示。这个分层几乎是照着教科书来的但真到了写代码的时候很多人会因为“图快”把逻辑和界面混在一起导致后面AI调优、加新功能比如悔棋、复盘时吃尽苦头。我建议一开始就严格分层——哪怕前期多写两行接口代码后面省的时间远不止两小时。2. 核心细节解析与实操要点2.1 棋盘表示方案二维数组够用吗黑白棋标准为8×8格子最直观的表示方式就是int board[8][8]。比如0表示空1表示黑子2表示白子。这样做的好处是代码可读性强调试时打印也方便比如写一个简单的函数把棋盘输出到控制台配合单元测试很舒服。但如果你对性能有更高追求比如做一个能深度搜索到10层以上的AI业界常用位棋盘Bitboard——用两个uint64_t整数分别表示黑子和白子的分布利用位运算完成翻转和合法位置计算。一次位运算能同时处理整行/整列/整斜线速度是二维数组方案的几十倍。我的建议是先基于二维数组把功能跑通因为位棋盘的可读性差位运算的Debug难度大不适合初学者直接上手。等把AI搜索深度从4层往上提的时候再做一次性能分析和迁移也不迟。接口上只要保证getBoard()、setPiece()这类函数稳定后续替换内部实现并不伤筋动骨。2.2 落子合法性判断与翻转逻辑这一部分是整个程序的核心千万别马虎。我这里给出一个常见的实现思路你直接照做就行。8个方向可以用方向向量来表示dx {-1, -1, -1, 0, 0, 1, 1, 1}dy {-1, 0, 1, -1, 1, -1, 0, 1}。对某个候选位置(row, col)依次检查每个方向向该方向走一步如果越界或遇到的不是对方棋子则continue。继续沿该方向走直到遇到空白格或走出边界——如果是空白格说明没有夹住如果是自己的棋子说明该方向可翻转。只要有一个方向可翻转该落子合法。翻转时再次沿这个方向走回去把路上的对方棋子全部变成自己的。注意翻转操作必须在确认该位置合法之后统一执行不能边检查边翻转不然方向判断会互相干扰。// 伪代码示意判断合法性 bool isLegal(int row, int col, int player) { if (board[row][col] ! EMPTY) return false; for (int d 0; d 8; d) { int r row dx[d], c col dy[d]; if (outOfBound(r, c) || board[r][c] EMPTY || board[r][c] player) continue; r dx[d]; c dy[d]; while (inBound(r, c) board[r][c] opponent(player)) { r dx[d]; c dy[d]; } if (inBound(r, c) board[r][c] player) return true; } return false; }这段代码我第一次写的时候走了弯路——我把“检查”和“翻转”写进了同一个函数里导致判断成功时棋盘已经被改了后续逻辑一片混乱。强烈建议把两个操作彻底分开hasLegalMove()只做判断applyMove()只做落子翻转。各司其职Debug体验天差地别。2.3 AI算法的选择与评估函数设计黑白棋AI这块我分两个阶段来写。第一阶段贪心策略初期版本最简单的AI是“能翻最多就下哪”。这个实现5分钟就能写完也很好验证。但实际对局你会发现它非常吃亏——黑白棋的一大特点是“让对手少行动”一个子翻得最多有时反而给对方创造了边角机会。贪心策略能打毫无章法的新手但打不过任何会看两步的人。第二阶段极小极大搜索 Alpha-Beta剪枝最终方案核心思路是假设双方都足够聪明我方选能让我方评估值最高的走法对方会选让我方评估值最低的走法。搜索到叶子节点时用静态评估函数打分。评估函数是黑白棋AI的“灵魂”。我的方案有四个部分按权重叠加子数差当前玩家棋子数减对方棋子数。这是最弱的指标因为黑白棋里子多不代表优势甚至可能“子多局面差”。边角优势角是黑白棋的必争之地因为它永远不会被翻转。四角给很高权重比如每个占有角50分。稳定子数量所谓稳定子就是永远不可能被对方翻转的己方棋子尤其角周围的边子链。这部分实现稍复杂但很有效。行动力Mobility当前玩家能下的合法位置数。黑白棋有一个原则——开局阶段让对手无棋可下比你多占几个子重要得多。我实盘测试也发现加强了行动力权重后AI整体水平提升极其明显。最终评估函数大概是score 自身合法步数*10 角数*50 稳定子数*20 - 对手合法步数*10 - 对手角数*50 - 对手稳定子数*15搜索深度我设置为4~6层。经过Alpha-Beta剪枝后即使不加置换表普通的8×8局面也能在几百毫秒内完成计算玩家体验基本流畅。2.4 界面绘制方案QPainter还是QLabel做棋盘界面最简单粗暴的方式是49个QLabel或者64个格子拼一个棋盘点击时给每个QLabel绑定事件。确实能跑但我强烈不推荐——刷新慢、代码冗余、后期想加动画效果几乎无从下手。更好的方案是自定义一个QWidget子类重写paintEvent()用QPainter一次性绘制整个棋盘。绘制要点包括棋盘底色与网格线绿色或木质风格背景用drawRect和drawLine画出8×8网格。棋子绘制用drawEllipse画圆填充黑色或白色边缘加一点渐变或描边效果视觉上会好很多。实测用QRadialGradient做棋子高光程序性能几乎不受影响观感提升却非常明显。合法位置提示在合法落子点画一个半透明小圆点用setOpacity轻松实现。这是新手玩家体验的关键功能没有它玩家会觉得“这游戏是不是坏了我点哪都不能下”。最后一手标记把上一步的落子位置用红框或特殊颜色圈起来方便双方观察局势。在鼠标事件方面在mousePressEvent()里通过坐标换算将像素坐标映射为棋盘行列坐标row event-pos().y() / cellSizecol event-pos().x() / cellSize然后调用逻辑层接口。注意要检查该格是否合法非法点击不做响应或给出提示。提醒paintEvent()里不要做任何耗时操作也不要在里面new对象。所有棋子数据的计算应该提前完成绘制函数只做“画”。如果你发现界面偶尔卡顿先检查是不是在paintEvent()里做了循环搜索。2.5 让界面与逻辑解耦信号槽的正确用法我的界面代码和逻辑代码通过信号槽通信典型的流程是玩家点击棋盘 →BoardWidget发送moveMade(int row, int col)信号。MainWindow里连接该信号到逻辑层GameController的槽函数。GameController调用Board::applyMove()修改状态再检查游戏是否结束。如果是人机模式轮到AI时调用AI::getBestMove()得到结果后再次更新界面。用信号槽的好处是当你某天想增加“网络对战”“AI自动对战”功能时不需要改动界面代码只需要新建一个类发出同样的moveMade信号即可。这是面向对象设计里“依赖倒置”原则的精髓。3. 实操过程与核心环节实现这一章直接上硬菜。我会按从界面到AI的顺序把关键代码和参数选择过程拆开讲。3.1 工程搭建从Qt Creator到第一版空棋盘我用的是Qt 5.15.2 Qt Creator 4.xWindows上安装时勾选MSVC 2019 64-bit组件如果你用MinGW编译器就勾选MinGW对应组件注意编译器位数和Qt库要一致这是最常见的坑。新建工程时选“Qt Widgets Application”基类选QMainWindow。创建完成后我习惯把自动生成的MainWindow拆成两个核心文件boardwidget.h/cpp实现棋盘的绘制和鼠标交互。mainwindow.h/cpp负责菜单栏、状态栏和游戏控制按钮的布局。在.pro文件里只需要保留基本的QT core gui widgets。如果后面你加音频、网络功能再额外加模块就行。BoardWidget里最关键的是记录单元格大小cellSize和棋盘偏移量margin。我用的是cellSize 60margin 30这样棋盘总宽为30*2 8*60 540像素窗口初始大小设置为600 x 600左右视觉效果刚好。// BoardWidget 核心绘制示例简化版 void BoardWidget::paintEvent(QPaintEvent *) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing); // 抗锯齿画圆必备 // 绘制棋盘背景 painter.setBrush(QColor(34, 139, 34)); painter.drawRect(margin - 5, margin - 5, cellSize * 8 10, cellSize * 8 10); // 绘制网格线 painter.setPen(QPen(QColor(255, 255, 255), 1)); for (int i 0; i 8; i) { painter.drawLine(margin i * cellSize, margin, margin i * cellSize, margin cellSize * 8); painter.drawLine(margin, margin i * cellSize, margin cellSize * 8, margin i * cellSize); } // 根据board状态绘制棋子 for (int r 0; r 8; r) { for (int c 0; c 8; c) { int val board[r][c]; if (val EMPTY) continue; QColor color (val BLACK) ? Qt::black : Qt::white; painter.setBrush(color); painter.drawEllipse(margin c * cellSize 4, margin r * cellSize 4, cellSize - 8, cellSize - 8); } } }3.2 游戏状态管理与胜负判断我单独写了一个GameController类负责维护当前轮到谁下、游戏状态进行中/已结束、统计棋子数并协调Board和AI。关键逻辑如下void GameController::onPlayerMove(int row, int col) { if (gameOver) return; if (currentPlayer ! HUMAN_PLAYER) return; if (!board.applyMove(row, col, currentPlayer)) return; // 切换玩家 currentPlayer opposite(currentPlayer); // 如果当前玩家无合法走法跳过 if (!board.hasAnyLegalMove(currentPlayer)) { currentPlayer opposite(currentPlayer); if (!board.hasAnyLegalMove(currentPlayer)) { gameOver true; updateScore(); emit gameFinished(blackCount, whiteCount); return; } } updateUI(); }有一个很容易踩的坑“玩家落子”和“AI落子”的入口不能混在一起。我一开始图省事在同一个函数里处理点击事件和AI返回结果结果AI和玩家互相“抢走子权”调试了一晚上才发现是状态没锁住。解决办法就是先判断currentPlayer再决定接受鼠标输入还是调用AI两者互不干扰。3.3 AI模块从评估函数到Alpha-Beta搜索AI实现我建议分两步。先完成一个无剪枝的极小极大版本游戏默认从第4回合左右进入中盘搜索深度3层时一个普通8×8局面大概要评估几十万局面速度还能接受。确认无误后再叠加Alpha-Beta剪枝深度可以提至5~6层。AI::getBestMove(Board board, int depth)返回一个QPairint, int表示落子坐标。我用的搜索框架如下int AI::alphaBeta(Board board, int depth, int alpha, int beta, int player) { if (depth 0) return evaluate(board, player); QListQPairint,int moves board.getLegalMoves(player); if (moves.isEmpty()) { // 无子可下切换玩家继续搜 if (!board.hasAnyLegalMove(opponent(player))) { return evaluate(board, player); // 双方都无子终局 } return -alphaBeta(board, depth - 1, -beta, -alpha, opponent(player)); } int best -1000000; for (auto move : moves) { Board newBoard board; newBoard.applyMove(move.first, move.second, player); int val -alphaBeta(newBoard, depth - 1, -beta, -alpha, opponent(player)); if (val best) best val; if (best alpha) alpha best; if (alpha beta) break; // 剪枝核心 } return best; }这段代码基于一个数学事实由于棋类游戏是对称的我可以将“轮到我下时取最大值”等价转化为“轮到我方时取最大值到对方时对评估结果取负号”。这样写代码时不用区分极大/极小两个函数逻辑也更紧凑。初学者如果对这里不熟建议先看我这一段再回去读算法书理解“负极大值”这套写法后AI代码会干净很多。3.4 评估函数里那些需要翻车的细节评估函数看起来简单但有很多雷区。第一个雷不要只用子数差评估。黑白棋的子数差在前20手基本没有参考意义盲目追子数的AI会被对手疯狂占角。我测试过纯子数差的AI和我写的带边角权重的AI对局10盘全输。第二个雷要注意评估在谁的角度。在负极大值框架里evaluate()返回的分数必须始终站在当前搜索节点对应的玩家角度即传入的player参数。如果统一返回固定玩家角度的分数交换玩家时会出大问题。我的做法是内部计算“当前玩家分数 - 对手分数”这样天然符合负极大值对评估的要求。第三个雷搜索深度和性能的平衡。我用Qt的QElapsedTimer实测过采用简单的二维数组Alpha-Beta剪枝不开置换表中盘局面深度4约耗时50~150ms深度5约200~600ms深度6则可能偶尔超过1秒。在人机对战中1秒内的等待可以接受但如果玩家级别是“困难”深度可以设5配合后续的迭代加深策略会更稳。3.5 界面细节落子动画与状态提示黑白棋其实不需要复杂的动画但加一点反馈能让程序质感完全不一样。我做了两个小功能落子翻转动画新落下的棋子先以半透明状态显示然后用QPropertyAnimation控制一个从0到1的量flipProgress在paintEvent()中依据该值把棋子宽度从100%缩到10%再扩展回来实现“翻转”的视觉错觉。实际只用了几行代码但效果非常像样。状态栏提示在MainWindow底部用QStatusBar显示“轮到黑棋/白棋”、“黑棋10 : 白棋22”、“游戏结束白棋胜”等信息。这不仅是给玩家的反馈调试时也能帮你确认AI的走子切换是否正常。这两个功能都不难但会让作品完成度上一个台阶。我的经验是棋类程序的UI不用炫酷把“状态可见性”做好玩家就会觉得你用心的程度非常高。4. 常见问题与排查技巧实录这里把我在开发中真实遇到的几个坑集中写一下按出现频率排序每一个都附上解决思路希望对你有用。事实上下面这些坑几乎每个刚从“CLI程序”转到“Qt桌面程序”的C学习者都会碰到至少两三个。4.1 Windows下启动报错“no Qt platform plugin could be initialized”这个报错我在新环境运行程序时遇到过一次原因是程序找不到Qt平台插件比如qwindows.dll。常见的触发场景是用Qt Creator直接运行时没问题但把debug或release目录下的exe单独拷走运行时就报错。解决方案有两个在Qt Creator里用Release模式编译然后在安装Qt的目录下找到windeployqt.exe对目标exe执行windeployqt.exe D:\build\reversi\release\reversi.exe它会自动把需要的Qt DLL和插件复制到exe旁边发布时把这个目录打包即可。如果还是报错检查环境变量PATH里是否包含Qt的bin路径。但注意真正发布给别人用的程序应该采用第一种方式而不是要求人家配置环境变量。4.2 点击棋盘没有反应程序却也没崩溃这是我刚写完界面时的头号问题。排查思路是先看控制台有没有qDebug()输出再在mousePressEvent()里加一行qDebug() row col;确认坐标转换是否正确。后来我发现问题出在事件被拦截了。如果BoardWidget的父窗口上有其他子控件比如一个透明的QLabel盖在棋盘上方鼠标事件会被那个控件吃掉BoardWidget永远接收不到点击。解决方法是把棋盘控件raise()到最上层或者在UI设计时注意控件的叠加层级。另外如果你重写了mousePressEvent()却忘了调用QWidget::mousePressEvent(event)也可能会导致后续事件处理不正常。虽然桌面程序里影响不大但养成正确的事件处理习惯总是好的。4.3 AI落子速度不稳定时快时慢理论上Alpha-Beta剪枝的搜索耗时应该和局面复杂度强相关。如果你发现某些局面的搜索时间异常长很可能是因为评估函数里调用了大量不必要的重复计算比如每次evaluate()时都重新计算所有稳定子而稳定子本可以在每次落子后增量更新。我的优化思路是在Board类里维护一个stableCache数组记录每个位置是否为稳定子。每次applyMove()后只更新受影响的区域比如该行、该列和两条对角线而不是全盘重算。这样中盘阶段搜索速度能提升20%~40%体感非常明显。如果你不想碰稳定子这么复杂的概念还有一个性价比很高的优化走法排序。优先搜索角、边以及靠近中心的位置Alpha-Beta剪枝的效率会大幅提升。测试里同样的深度4简单排序前后的耗时差距能到2倍以上。4.4 中文显示乱码在Qt 5里中文字符串一般只要源文件保存为UTF-8编码编译时加上/utf-8参数MSVC或者统一使用QStringLiteral基本不会乱码。我在Windows MinGW环境下没遇到问题但MSVC编译器有时不认UTF-8无BOM的源文件导致中文字符串变成乱码。解决办法是在.pro文件里加一行QMAKE_CXXFLAGS /utf-8另外代码里不要用std::string存储界面显示文本一律用QString避免编码转换引入额外问题。4.5 游戏无法判定“双方都无合法走法”这是我上面提过的逻辑Bug只检查了当前玩家是否无合法走法就判定游戏结束但没检查对手是否也无合法走法。正确顺序是当前玩家无合法走法 → 切换玩家。切换后的玩家也无合法走法 → 游戏结束。否则继续游戏由新玩家行动。把这段逻辑单独抽成一个checkGameEnd()函数每次走完子后调用比散落在各个分支里要清晰得多。bool GameController::checkAndHandlePass() { if (board.hasAnyLegalMove(currentPlayer)) return false; // 当前玩家无法行动pass currentPlayer opposite(currentPlayer); if (board.hasAnyLegalMove(currentPlayer)) { emit infoMessage(对方无合法走法已跳过); return false; } // 双方都无法行动结束 currentPlayer EMPTY; // 标记游戏结束 gameOver true; return true; }5. 后期的扩展方向与个人建议如果你按上面这套写完已经是一个界面美观、人机可战的完整黑白棋了。但我的经验是一个作品最能体现“工程能力”的地方往往不是第一个能跑的版本而是后续一步步打磨的过程。几个我试过且值得做的扩展方向AI难度分级简单/普通/困难通过控制搜索深度和评估函数的噪声来实现。简单模式固定深度1~2困难模式深度5~6实测难度差异非常明显。悔棋与复盘这里就体现出“逻辑层与界面层分离”的好处了——你只需要在GameController里维护一个历史栈QStackBoardState落子前压栈悔棋时出栈重绘就行。保存/加载对局用QJsonDocument把当前棋盘状态、当前玩家、历史记录写入JSON文件下次启动时恢复。QT自带的JSON支持让这个功能半小时内就能搞定。网络对战进阶使用QTcpSocket或QUdpSocket一台机器创建服务器另一台连接之后同步的只是“坐标”逻辑层完全复用。最后分享我个人的一个体会很多人学C会觉得“语法都会就是写不出完整的项目”。黑白棋这玩意儿的魅力就在于——它规则极其简单算法深度却足够打磨两周同时在自动机、界面编程、算法设计上都给了你充分的练习空间。我做完这一版后再回头看那些“C八股文”里的虚函数、多态、智能指针突然就通透了——因为你真的在代码里用到了它们而不是在面试题里背到它们。如果你也在做类似的棋类项目卡在某个环节上不妨照着我的方案先跑通一遍再对比自己的设计看哪里可以改进。棋类游戏最有趣的从来不是“赢”而是你在不断和“昨天的自己”对弈。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →