
简介一份基于Qt框架的黑白棋翻转棋完整游戏源码包面向Qt入门者与游戏开发初学者展示如何借助QWidget、QGraphicsView等组件从零搭建棋盘界面、交互事件与对战逻辑。资源共三十个文件压缩包约一点四MB内含六个C源文件、三个头文件、Qt工程文件pro/ui/qrc及多张png/jpg棋盘棋子素材还带有编译生成的o文件和makefile便于直接构建运行或改动调试。项目完整覆盖黑白棋核心规则轮流落子、合法位置判定、棋子翻转、胜负结算并将棋盘状态用二维数组管理适合对照学习事件驱动编程、GUI布局、面向对象设计与资源加载流程。已有236人学习浏览对想快速上手Qt小游戏开发、理解游戏框架分层的读者来说是一份短小精悍的参考样例既可直接运行体验也可作为扩展AI对手或联机对战功能的起点。1. 基于Qt的黑白棋游戏源码.zip最值钱的是引擎不是界面把一份带AI的黑白棋源码包交给别人评审十有八九先点开界面截图的是新手老手会直接翻AI引擎和棋盘模型那两三百行。基于Qt的黑白棋游戏源码.zip 这种工程难度从来不在配色和圆角而在把规则翻译成不出错的棋盘模型、再叠一套能赢棋的搜索逻辑。它既可以是一份Qt课程设计作业也是理解极小极大搜索和α-β剪枝的最小实验场。适合三类人刚学完C想用Qt做完整项目的学生、想快速复用一套人机对战界面的开发者以及想搞懂博弈树剪枝但不想啃国际象棋引擎源码的人。下面从规则引擎开始拆界面和AI按依赖顺序讲。2. 先把规则翻译成代码棋盘模型与8方向扫描2.1 棋盘数据结构和坐标约定黑白棋Reversi标准棋盘是8×8黑先白后落子必须夹住至少一枚对方棋子才能翻转双方都没棋可下时结束数子定胜负。开局四子固定在棋盘中央黑子在(3,4)和(4,3)白子在(3,3)和(4,4)。这套源码第一步值得抄的是引擎层完全不依赖Qt的纯C类设计。// board.h核心引擎不依赖 Qt可直接命令行测试 enum Cell { EMPTY 0, BLACK 1, WHITE 2 }; class ReversiBoard { public: ReversiBoard() { reset(); } void reset() { for (auto row : grid) row.fill(EMPTY); grid[3][3] WHITE; grid[3][4] BLACK; grid[4][3] BLACK; grid[4][4] WHITE; currentPlayer BLACK; } int cell(int r, int c) const { return grid[r][c]; } bool isLegalMove(int r, int c, int color) const; void applyMove(int r, int c, int color); QVectorQPairint,int legalMoves(int color) const; private: std::arraystd::arrayint, 8, 8 grid{}; int currentPlayer BLACK; };把引擎设计成不依赖Qt的纯C类是这套源码最值得保留的分层习惯。原因有三一可以直接写命令行测试用例不用开界面就能验证规则正确性二后续AI搜索时复制棋盘非常便宜8×8的数组拷贝可以忽略不计三课程设计答辩时能清晰讲出「界面层和逻辑层解耦」这是最常见的加分点。这里的EMPTY/BLACK/WHITE用枚举而不是魔法数字1、2读代码和写switch时都不容易晕。std::array定长数组比QVector少一层堆分配数据连续存放后面做存档和悔棋可以用memcpy直接序列化这个优势到第6章讲快照栈时你会感受到。2.2 合法落子判断8方向扫描的细节合法判断是整个引擎最容易翻车的地方。规则看起来只有一句话「夹住对方棋子」落到代码上要处理8个方向、边界、空格和同色棋子四种边界情况。我一般这样写bool ReversiBoard::isLegalMove(int r, int c, int color) const { if (r 0 || r 8 || c 0 || c 8) return false; if (grid[r][c] ! EMPTY) return false; const int dir[8][2] { {-1,-1}, {-1,0}, {-1,1}, {0,-1}, {0,1}, {1,-1}, {1,0}, {1,1} }; for (auto d : dir) { int nr r d[0], nc c d[1]; bool hasOpponent false; while (nr 0 nr 8 nc 0 nc 8) { if (grid[nr][nc] EMPTY) break; if (grid[nr][nc] color) { if (hasOpponent) return true; break; } hasOpponent true; nr d[0]; nc d[1]; } } return false; }核心是hasOpponent这个标志位。沿某个方向前进时只有遇到过至少一枚对方棋子再遇到己方棋子才能算夹住。这里有个新手高频误写扫描到同色就立刻return true完全不检查中间是否夹过对手棋子结果对手棋子紧贴空格的位置也被判成合法。另一个常见bug是hasOpponent没在每个方向内重置第一轮方向夹到了棋子第二轮方向其实没夹到但标志残留导致整个位置误判成合法。方向数组用8×2的C风格数组而不是QVector2600行级别的棋盘扫描里这种连续内存的局部变量对CPU缓存更友好。参数color传BLACK或WHITE函数内不修改棋盘状态const成员函数可以放心在UI线程和AI搜索线程同时调用。2.3 applyMove与翻转先收集坐标再统一翻转落子翻转的常见错误是边扫描边翻转结果翻到一半改变了棋盘上的颜色分布后续方向判断全部错乱。我一般先收集所有需要翻转的坐标全部判断完再统一落子。void ReversiBoard::applyMove(int r, int c, int color) { QVectorQPairint,int toFlip; const int dir[8][2] { /* 同 isLegalMove */ }; for (auto d : dir) { int nr r d[0], nc c d[1]; QVectorQPairint,int line; while (nr 0 nr 8 nc 0 nc 8) { if (grid[nr][nc] EMPTY) break; if (grid[nr][nc] color) { toFlip line; break; } line.append({nr, nc}); nr d[0]; nc d[1]; } } if (toFlip.isEmpty()) return; // 防御非法调用直接忽略 grid[r][c] color; for (auto p : toFlip) grid[p.first][p.second] color; currentPlayer 3 - color; // 3-123-21实现黑-白切换 }toFlip存的是坐标而不是颜色因为统一翻转时所有目标格子最终都是color。3 - color这个写法第一次看有点绕但它是黑白棋引擎里最常见的玩家切换惯用法比color BLACK ? WHITE : BLACK少写几行也避免魔法数字。这里还有一个容易被忽略的规则如果一个玩家当前没有任何合法落子应该跳过他的回合由对方继续下。跳过的判断在legalMoves为空时触发博弈树搜索里同样要处理这个分支否则AI会漏掉「让对手无子可下」的战术。配套的辅助接口长这样QVectorQPairint,int ReversiBoard::legalMoves(int color) const { QVectorQPairint,int moves; for (int r 0; r 8; r) for (int c 0; c 8; c) if (isLegalMove(r, c, color)) moves.append({r, c}); return moves; }拿到源码包后先把上面这段在命令行里跑一遍自检逻辑再上界面。规则引擎如果有问题后面所有AI和界面都是在错误的地基上盖楼这一点不是玄学是血泪经验。3. 用Qt画棋盘paintEvent、双缓冲与坐标换算3.1 为什么选QWidget而不是QGraphicsView黑白棋界面需求很简单8×8网格、两种颜色的圆形棋子、最后一手标记、落子提示。用QWidget重写paintEvent不到五十行就能画完QGraphicsView适合元素数量成百上千、需要拖拽缩放编辑的场景对固定网格属于杀鸡用牛刀。而QML方案虽然动画好看但和课程设计里大部分C代码风格割裂调试信号槽反而多一道门槛。所以这套源码如果用QWidget方案优先读它的绘制和鼠标事件两个部分就够了这也是qt界面设计里出镜率最高的一套组合拳。3.2 棋盘绘制从网格线到抗锯齿棋子棋盘绘制最忌讳把每个格子当成独立控件去摆QLabel或QPushButton窗口缩放时对不齐、锯齿明显、代码还长。直接在一个QWidget上重写paintEvent是最常见的做法void BoardWidget::paintEvent(QPaintEvent *) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing); const int side qMin(width(), height()); // 棋盘永远是正方形 const int cell side / 8; const int ox (width() - cell * 8) / 2; // 水平居中偏移 const int oy (height() - cell * 8) / 2; // 垂直居中偏移 painter.setBrush(QColor(0x2d, 0x6e, 0x3e)); // 棋盘绿底 painter.drawRect(ox, oy, cell * 8, cell * 8); painter.setPen(Qt::black); for (int i 0; i 8; i) { painter.drawLine(ox i * cell, oy, ox i * cell, oy cell * 8); painter.drawLine(ox, oy i * cell, ox cell * 8, oy i * cell); } for (int r 0; r 8; r) { for (int c 0; c 8; c) { int v board-cell(r, c); if (v EMPTY) continue; painter.setPen(QPen(v BLACK ? Qt::black : Qt::white, 1)); painter.setBrush(v BLACK ? Qt::black : Qt::white); int gap cell / 8; painter.drawEllipse(ox c * cell gap, oy r * cell gap, cell - gap * 2, cell - gap * 2); } } }这段绘制逻辑的关键不是画圆而是ox和oy的居中计算。窗口拉宽时棋盘不跟着变形永远保持在中央正方形区域两侧留白由窗口自己消化。gap让棋子和网格线之间留出空隙视觉上更像真实的棋子而不是贴上去的色块。Antialiasing必须在绘制前开启否则圆形边缘在非整数坐标下会出现明显锯齿这是qt绘图里最容易看出来水平高低的一个细节。关于双缓冲现代Qt的QWidget在大多数平台默认已经由窗口系统做了后台缓冲直接绘制不会闪烁。真正导致闪烁的往往是第5章要讲的「在paintEvent里调update()」这种自我触发的重绘风暴而不是缺少手动QPixmap离屏缓冲。3.3 鼠标交互点击坐标到棋盘格子的换算鼠标点击和绘制必须共用同一套ox/oy/cell否则就会出现「棋子画在A格点击响应在B格」的错位。换算公式是(物理坐标 - 偏移量) / 格子边长void BoardWidget::mousePressEvent(QMouseEvent *e) { if (!myTurn) return; // AI思考时禁止玩家落子 const int side qMin(width(), height()); const int cell side / 8; const int ox (width() - cell * 8) / 2; const int oy (height() - cell * 8) / 2; int col (e-pos().x() - ox) / cell; int row (e-pos().y() - oy) / cell; if (row 0 || row 8 || col 0 || col 8) return; if (!board-isLegalMove(row, col, myColor)) return; board-applyMove(row, col, myColor); lastMove {row, col}; update(); // 只请求重绘不直接调 paintEvent emit moveCommitted(row, col); }(e-pos().x() - ox) / cell用的是整数除法余数直接丢弃天然得到格子索引。注意先减偏移再除格子边长顺序反了会在窗口非正方形时出现系统性偏移。点击落在棋盘外直接return这是最容易漏的边界不处理的话玩家点窗口边缘会导致row或col等于8甚至负数再去访问grid就越界了。myTurn这个门由外部控制它的核心作用不是防玩家手快而是防止AI在后台搜索时棋盘被玩家修改导致数据竞争。3.4 信号槽串起人机对弈流程人机对弈的完整流程是玩家落子 → 触发AI线程搜索 → AI回着 → 界面刷新。这里最常见的翻车是直接在moveCommitted信号里同步调用AI搜索深度一到4层界面卡死几百毫秒用户以为程序崩溃了。常见做法是用QtConcurrent::run把搜索丢到线程池结果通过QMetaObject::invokeMethod切回主线程更新UIconnect(this, BoardWidget::moveCommitted, this, [this](int r, int c) { setMyTurn(false); QtConcurrent::run([this]() { auto move ai-bestMove(*board, AI_COLOR); // 阻塞搜索 QMetaObject::invokeMethod(this, [this, move]() { if (move.first 0) { board-applyMove(move.first, move.second, AI_COLOR); lastMove move; update(); } setMyTurn(true); }, Qt::QueuedConnection); }); });QtConcurrent::run把搜索任务丢进Qt全局线程池不阻塞UI线程QMetaObject::invokeMethod配合Qt::QueuedConnection把更新切回主线程执行这是Qt异步里最简洁的写法比手写QThread加信号少一半样板代码。需要注意lambda捕获了this窗口如果先于线程结束被销毁线程里访问board会崩溃解决方案是MainWindow析构时调用QThreadPool::globalInstance()-waitForDone()这个坑第5章会单独讲。4. AI设计从贪心到带α-β剪枝的负极大值搜索4.1 贪心AI先跑通全流程再谈棋力最基础的AI是每次选择能翻转最多棋子的位置实现只需遍历合法落子列表取最大值QPairint,int GreedyAI::bestMove(const ReversiBoard b, int color) { auto moves b.legalMoves(color); QPairint,int best {-1, -1}; int maxFlip -1; for (auto m : moves) { int flipCount b.countFlip(m.first, m.second, color); if (flipCount maxFlip) { maxFlip flipCount; best m; } } return best; }countFlip可以在isLegalMove基础上改成计数版本合法判断返回布尔计数判断多一个累加翻转坐标数量的分支全程不修改棋盘状态。贪心的棋力很弱它只顾眼前能吃多少子中后期经常为了多吃两个子把角送给对手。但贪心有两个实际用途一是作为整个对弈流程的冒烟测试——界面、信号槽、线程调度有没有问题跑一局就知道二是作为minimax的基线后面替换成搜索AI时用同一局面对比着法能快速定位是搜索逻辑坏了还是评估函数符号反了。4.2 负极大值搜索与α-β剪枝黑白棋的标准AI框架是极小极大搜索。我把max和min统一成负极大值写法递归代码更短且不容易在max/min切换时写错视角int ReversiAI::negamax(ReversiBoard b, int color, int depth, int alpha, int beta) { auto moves b.legalMoves(color); if (moves.isEmpty()) { auto oppMoves b.legalMoves(3 - color); if (oppMoves.isEmpty()) return b.evaluate(color); // 终局 return -negamax(b, 3 - color, depth - 1, -beta, -alpha); // 跳过回合 } if (depth 0) return b.evaluate(color); int best -INF; for (auto m : moves) { ReversiBoard nb b; nb.applyMove(m.first, m.second, color); int score -negamax(nb, 3 - color, depth - 1, -beta, -alpha); if (score best) best score; if (best alpha) alpha best; if (alpha beta) break; // 剪枝对手不会让你走这条线 } return best; }负极大值的核心逻辑是每个递归层都假设「当前行棋方」最大化自己的收益子节点的评分为负号。这样不需要区分max层和min层搜索树的每个节点代码完全一致。容易翻车的地方在第5章细说这里先记住两个参数调用入口是negamax(board, color, depth, -INF, INF)递归时传给对手的alpha/beta是-beta, -alpha两个参数一起取负再交换少一个负号都会让剪枝条件失效。每次递归都复制一份ReversiBoard而不是落子后再撤销这个选择是故意的。8×8数组拷贝开销极小而撤销操作要额外记录上一步翻转了哪些棋子、当前玩家是谁出错概率远高于拷贝。性能方面深度4的搜索在普通笔记本上大约几十到几百毫秒配上第3章的异步线程正好流畅深度6就要看评估函数复杂度可能到秒级。4.3 评估函数位置权重表与稳定子评估函数决定AI的棋风。黑白棋界的通用共识是「角是命门」占角的棋子永远不会被翻转而角斜相邻的C位是最危险的位置落子C位等于帮对手占角。静态位置评估是性价比最高的起点位置类型权重理由角如0,035永久稳定不可翻转C位角斜相邻-20高概率送角给对手边非角5次稳定区域被翻概率低内部普通格1只做基础计数实现时直接定义一张8×8的静态权重表评估函数查表累加。比静态表更进一步的是稳定子统计从某个已占的角出发沿整行、整列、对角线方向清点同色连串棋子这些是真正「翻不回去」的棋子按每个额外加权。行动力合法落子数也是黑白棋的重要维度前期权重尤其高因为落子数多意味着选择多、主动权强。最终的评估值公式我一般写成int ReversiBoard::evaluate(int color) const { int myWeight 0, oppWeight 0; // 1. 静态位置权重查表累加 // 2. 稳定子加权 countStable(color) - countStable(3 - color) // 3. 行动力加权 mobilityFactor * (legalMoves(color).size() - legalMoves(3-color).size()) return (myWeight - oppWeight) stableFactor * stableDiff mobilityFactor * mobileDiff; }mobilityFactor取值范围0.1到0.3stableFactor建议20到30。这些参数没有标准答案我调参的习惯是让AI自我对弈100局黑棋白棋各一半用胜率做指标每次只改一个系数。别指望一次调对评估函数本质是玄学加实验先跑起来再慢慢磨。5. 黑白棋源码落地避坑从zip解压到引擎调参5.1 zip解压后的目录层级与中文路径现象从zip解压后直接用Qt Creator打开pro文件构建提示找不到源文件或资源里引用的图片、QSS加载不出来。原因这份源码包解压后通常自带一层以工程名命名的目录pro文件在里层如果解压路径带中文比如D:\下载\黑白棋源码旧版Qt Creator和MSVC编译器处理带中文的构建路径会出预料之外的问题有时是moc这一步失败有时是资源文件路径乱码。解决解压后先检查顶层目录结构把pro文件所在目录整个拷贝到纯英文路径例如D:\dev\reversi-qt然后在Qt Creator里删除旧的构建目录重新执行qmake再构建。资源加载不到的问题检查pro文件里RESOURCES和这边路径是否相对pro所在目录写的源码包里常见的坑是使用了./开头但实际资源在上级目录。5.2 Qt5与Qt6的鼠标事件API不一致现象源码在Qt 5上编译通过换到Qt 6报错pos is not a member of QMouseEvent。原因Qt 6把QMouseEvent::pos()移除了改为position()返回QPointF。解决在鼠标事件里做一次版本兼容这是移植老源码最典型的补丁#if QT_VERSION QT_VERSION_CHECK(6, 0, 0) QPoint pos e-position().toPoint(); #else QPoint pos e-pos(); #endif拿到老源码先确认它适配的Qt版本很多课程设计代码基于Qt 5.12写成拖到Qt 6.5编译不止这一处差异QRegExp、QTextCodec等一批类在Qt 6里都被替换或移除逐个处理时需要有点耐心。如果不想折腾直接装Qt 5.15 LTS跑老源码是最省事的路径。5.3 paintEvent里调用update()导致重绘风暴现象窗口拖动或鼠标划过棋盘时CPU占用飙升界面闪烁严重时直接无响应。原因新手为了让棋子动态效果更流畅在paintEvent里调用了update()每次paintEvent结束立刻触发下一次形成无限重绘循环。解决paintEvent里只读状态、只画画任何状态变更都放到事件处理函数里。update()的意思是「我这里有状态变了请安排一次重绘」而不是「立刻重绘」。另一个闪缩来源是QOpenGLWidget需要在构造时调用setUpdateBehavior(QOpenGLWidget::NoPartialUpdates)否则局部更新策略在画网格线时会闪。判定标准很简单如果paintEvent里出现了任何可能触发事件循环的调用基本就是重绘风暴源头。5.4 negamax符号反了AI白送角现象AI用贪心算法时还能下换成minimax后棋力反而下降专门送角、送边。原因负极大值写法里递归子节点的评分忘记取负或者评估函数的视角和调用处不一致。这是博弈搜索最经典的符号翻车症状就是AI每步都选评分最低的着法。解决写一个调试函数在根节点打印每种候选着法的评分。构造一个简单局面人工验证比如某个落子能翻转两枚棋子它的评分在color视角下必须为正如果输出全为负查-negamax(nb, 3 - color, depth - 1, -beta, -alpha)这一行的负号以及evaluate内部是否统一返回「当前传入color视角」的分数。还有一个细节alpha-beta换手时是(-beta, -alpha)两个参数一起交换顺序反了剪枝条件虽然不报错但会剪掉真正的最优分支。5.5 高清屏缩放下鼠标点不准现象Windows 150%缩放下棋子显示位置和点击落点错位越靠右下角偏得越厉害。原因Qt 5默认不启用高DPI缩放界面按逻辑坐标渲染鼠标事件返回的却是物理坐标而Qt 6默认启用缩放这时代码若又手动乘以devicePixelRatio会造成双重缩放。解决Qt 5在main()函数最前面加QApplication::setAttribute(Qt::AA_EnableHighDpiScaling)鼠标事件统一用e-position().toPoint()这类逻辑坐标不要自己手动乘缩放系数。Qt 6下这些会自动处理前提是绘制代码里别写死像素值。5.6 AI线程访问已销毁的窗口导致崩溃现象关闭程序时偶发崩溃调试器定位在QtConcurrent的lambda里访问了this。原因QtConcurrent::run的任务还在线程池里跑MainWindow已经被析构lambda捕获的this成了野指针。解决MainWindow析构函数里先调用QThreadPool::globalInstance()-waitForDone()确保所有后台任务结束再销毁成员对象。更稳妥的做法是给AI搜索加一个原子取消标志析构时置位搜索循环里每层检查一次。这个崩溃带有随机性因为线程调度时序不定越是在性能好的机器上越容易出现属于典型的「平时不崩、演示时崩」的坑。6. 进阶悔棋快照、对局记录与引擎自检6.1 悔棋快照栈比撤销操作列表更省心悔棋功能的常见做法是记录每一步翻转了哪些棋子撤销时逐格恢复。这个方案容易出错落子时记漏一枚、白棋黑棋状态恢复反了都会让棋盘越撤销越乱。我一般直接用快照栈棋盘固定8×8整个状态序列化成一个QByteArray落子前把当前棋盘push进栈悔棋时直接从栈顶恢复void MainWindow::pushUndoSnapshot() { QByteArray snap(sizeof(ReversiBoard), Qt::Uninitialized); memcpy(snap.data(), board, sizeof(board)); undoStack.push(snap); } void MainWindow::undo() { if (undoStack.isEmpty()) return; memcpy(board, undoStack.pop().constData(), sizeof(board)); lastMove {-1, -1}; update(); }这里sizeof(ReversiBoard)是合法的因为ReversiBoard里的std::array是标准布局类型内存连续直接镜像拷贝就是完整状态。这种技巧适合固定大小的棋类棋盘五子棋、国际象棋都能照搬。快照栈的代价是内存占用随步数线性增长但一局黑白棋最多60步每步几百字节完全不是问题。6.2 对局记录与引擎自检对局记录不需要引入复杂的通用棋谱格式自定义一套坐标文本就够了每步追加一行黑 d3、白 e6这类文本结束后导出txt既方便复盘也能用于测试AI。坐标转文本的映射函数注意先转列再转行棋盘从左到右是a到h从上到下是1到8这一步写反了整个棋谱就是镜像的。引擎自检是源码包里最容易跳过的部分但棋盘游戏最不值得信任的就是规则代码。我习惯在main()里塞一个selfTest()函数用几个已知局面做断言void selfTest() { ReversiBoard b; Q_ASSERT(b.legalMoves(BLACK).size() 4); // 初始局面黑方4个合法点 b.applyMove(2, 3, BLACK); // 模拟黑方下 d3 Q_ASSERT(b.cell(3, 3) BLACK); // 中央白子被翻转 Q_ASSERT(b.cell(2, 3) BLACK); // 落子点本身有子 // 再模拟一局到底最终棋盘子数必须等于64 }这类断言跑一遍只需要几毫秒却能挡住八成低级错误。我自己带项目的一个习惯是每改一次规则或AI代码先跑引擎自检再开界面等自检过了才碰绘制和信号槽。有段时间我把评估函数加到了七八个权重项搜索速率肉眼可见地变慢砍掉三个冗余参数后棋力反而更稳。棋盘这类项目规则引擎是地基界面是装修先把自检跑吐了再做AI后面才不会反复推倒重来。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。