ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Qt黑白棋游戏源码解析:规则层、界面绘制与AI实现

Qt黑白棋游戏源码解析:规则层、界面绘制与AI实现 简介这份基于Qt的黑白棋游戏源码包适合Qt初学者与游戏开发爱好者学习GUI编程与策略游戏实现可用于完成课程设计或作为同类棋类项目的基础框架。压缩包共30个文件大小约1.4MB包含cpp源代码、h头文件、ui界面文件、pro工程文件以及png/jpg图片资源代码按棋盘类、棋子类、游戏逻辑类等模块划分结构清晰便于检索和复用。目前已有236人学习浏览示例受到一定关注。项目完整实现了黑白棋的轮流落子、翻转棋子与胜负判断等核心逻辑并结合Qt事件驱动与界面布局展示了QGraphicsView、QWidget等组件的实际用法。通过阅读和修改源码能够掌握二维数组建模棋盘、面向对象设计、资源文件加载等技巧对深入理解跨平台GUI应用开发具有较好的参考价值。1. 基于Qt的黑白棋源码解压之后你要面对的不只是一张棋盘下载一个名为“基于Qt的黑白棋游戏源码.zip”的压缩包解压后通常是一堆.pro、.cpp、.h和资源文件。很多人双击.pro就等窗口弹出来结果要么报依赖路径错误要么看到能落子却压根不吃子。黑白棋真正花时间的不是画棋盘而是把“吃子规则”和Qt的界面刷新机制接对。这篇文章顺着这个标题往下走先把无界面的规则层做扎实再谈QPainter绘制、AI权重、编译套件和踩坑排查。它适合正在做Qt课程设计、想把控制台黑白棋升级成带界面作品或对简单博弈AI感兴趣的从业者。2. 先让黑白棋“能玩”把合法落子、吃子与胜负判定压进一个无界面的Board类我拿到任何带界面的棋类源码第一件事是把界面文件全部放到一边先把规则层单独编译成一个控制台程序。黑白棋的规则并不复杂但边界多越界、空位、夹住、无棋可走、棋盘满。这些如果不做独立验证后面所有绘制和AI都是空中楼阁。这一章把Board类写完整它是整个工程的地基。2.1 8×8棋盘的数据结构用二维数组当底层模型别让QGraphicsScene掺和规则黑白棋棋盘固定8×8最稳的数据结构就是二维数组。有人习惯用QGraphicsScene里的图元充当棋子再把图元位置当数据读回来这套路看着直观但一旦要做AI搜索、随机对局测试你就得一遍遍从QGraphicsItem里捞数据还容易拿到脏指针。我一般把Board类设计成不依赖任何Qt图形库的纯逻辑类只有QVector和QPoint这些基础类型参与方便以后单独编译测试。#ifndef BOARD_H #define BOARD_H #include QVector #include QPoint class Board { public: Board(); void reset(); // 初始化中心四子 bool isLegal(int row, int col, int player) const; void placeDisc(int row, int col, int player, QVectorQPoint *flipped nullptr); bool hasAnyLegalMove(int player) const; bool isFull() const; int countDiscs(int player) const; static const int SIZE 8; int grid[SIZE][SIZE]; // 0空 1黑 -1白 private: static const int DIRS[8][2]; }; #endif这种设计的核心意图是“数据与表现分离”。grid数组直接暴露为public是为了让AI求值和测试程序能快速读盘面不至于写一堆getter。DIRS是8个方向向量对应上下左右和四条对角线。reset()负责把黑白棋的初始四子摆好黑棋在(3,4)和(4,3)白棋在(3,3)和(4,4)这个初始布局是规则里写死的。#include board.h #include cstring const int Board::DIRS[8][2] { {-1,-1}, {-1,0}, {-1,1}, { 0,-1}, { 0,1}, { 1,-1}, { 1,0}, { 1,1} }; Board::Board() { reset(); } void Board::reset() { std::memset(grid, 0, sizeof(grid)); grid[3][3] -1; grid[3][4] 1; grid[4][3] 1; grid[4][4] -1; }std::memset清零和四子初始化都在reset里构造函数直接调reset这样每次新开一局只需要重新构造或调用reset不需要手动清数组。方向向量里中间一行缺了(0,0)那是“原地不动”不属于合法方向。2.2 合法落子检测夹住对方棋子才算边界和空白都要查黑白棋的合法落子条件是落子位置为空且至少在一个方向上能“夹住”至少一枚对方棋子也就是从落点出发沿某个方向连续遇到对方棋子最后遇到一枚己方棋子。很多初版代码只检查八个方向是否“紧邻对方棋子”这是错的。比如对手棋子隔一个空格也能被夹只不过中间不能有任何己方或空白阻挡。bool Board::isLegal(int row, int col, int player) const { if (row 0 || row SIZE || col 0 || col SIZE) return false; if (grid[row][col] ! 0) return false; int opponent -player; for (int k 0; k 8; k) { int r row DIRS[k][0]; int c col DIRS[k][1]; bool hasOpponent false; while (r 0 r SIZE c 0 c SIZE) { if (grid[r][c] opponent) { hasOpponent true; r DIRS[k][0]; c DIRS[k][1]; } else if (grid[r][c] player hasOpponent) { return true; } else { break; } } } return false; }这里的逻辑分层很关键。第一步做越界和空格检查第二步沿每个方向推进。hasOpponent这个布尔值记录“这个方向上是否已经看到过对方棋子”只有先看到对方、再看到己方才能判定这个方向合法。如果中途遇到空白或棋盘边界直接break换下一个方向。注意player用1和-1表示两方opponent就是-player这样黑棋白棋共用同一套代码不需要额外的颜色判断分支。很多从C语言转过来的朋友习惯把玩家编号写成1和2结果isLegal里全是if (player 1)的硬编码。用正负号表示阵营opponent直接取负后面AI搜索里“走一步之后换边”也只需要传-player代码会干净很多。这部分我强烈建议照抄而不是自己去发明一套1和2的表示法。2.3 吃子翻转与回合跳过placeDisc返回翻转列表hasAnyLegalMove处理pass合法落子检测只能告诉你“能不能下”真正改变盘面的是placeDisc。它的核心逻辑和isLegal几乎一样区别在于isLegal一发现合法方向就返回true而placeDisc需要完整走完8个方向把每个方向上连续遇到的对方棋子全部翻过来。为了支持界面层做动画效果我会用一个QVectorQPoint把翻转的棋子位置带出去。void Board::placeDisc(int row, int col, int player, QVectorQPoint *flipped) { grid[row][col] player; int opponent -player; for (int k 0; k 8; k) { int r row DIRS[k][0]; int c col DIRS[k][1]; QVectorQPoint line; while (r 0 r SIZE c 0 c SIZE grid[r][c] opponent) { line.append(QPoint(r, c)); r DIRS[k][0]; c DIRS[k][1]; } // 这个方向末端是己方棋子才执行翻转 if (r 0 r SIZE c 0 c SIZE grid[r][c] player !line.isEmpty()) { for (const QPoint p : line) { grid[p.x()][p.y()] player; if (flipped) flipped-append(p); } } } }注意这里有一个和isLegal的细微差别placeDisc的while条件把grid[r][c] opponent直接写进循环意味着连续扫过对方棋子后要么遇到己方棋子、遇到空位、撞到边界三种情况都会退出。退出后判断末端是不是己方棋子和line非空两个条件同时满足才翻转。这里不能用“如果grid变成player就翻转”而忽略line为空的情况因为紧邻的同色棋子不算被夹住line为空时翻转会把刚刚落下的子周边清空属于逻辑bug。有了placeDisc回合切换就顺理成章了。但黑白棋有个特殊规则当前玩家无棋可走时要自动跳过回合把机会让给对方。所以界面层不能傻傻地“每次落子后切到对方”必须先检查对方还有没有合法落子。bool Board::hasAnyLegalMove(int player) const { for (int r 0; r SIZE; r) for (int c 0; c SIZE; c) if (isLegal(r, c, player)) return true; return false; } bool Board::isFull() const { for (int r 0; r SIZE; r) for (int c 0; c SIZE; c) if (grid[r][c] 0) return false; return true; }hasAnyLegalMove就是一次简单的64格扫描把每个空位交给isLegal判断。这个函数在四个地方都要用回合切换时、终局判断时、AI找候选点时、界面画“提示可落子位置”时。isFull负责排查棋盘满的情况。终局的完整判定是“棋盘满或黑白双方都无合法落子”。注意“某一方无棋可走”不算终局只有双方都无棋可走才算这一点在第五节会展开讲。一个完整的落子流程应该是先检查终局没有终局判断谁该走当前玩家有合法落子就正常走没有就把回合交给对方如果对方也没有游戏结束。这套逻辑放在界面层还是放在一个单独的GameController类里都可以我的习惯是放在Widget的槽函数里但逻辑层和显示层仍然通过信号解耦。3. QPainter绘图与信号槽棋盘坐标换算和刷新机制决定这个游戏“像不像样子”规则层能跑起来之后才轮到QPainter上场。这一章解决从“数据”到“画面”的映射70%的界面问题出在坐标换算不一致剩下的30%出在信号槽连接不对。我一般把绘制和交互放在同一个BoardWidget里逻辑仍然由外部的Controller调用Board完成。3.1 从QPoint到行列号用四舍五入而不是截断棋盘绘制的难点不在QPainter本身而在于“鼠标点在像素坐标上怎么换算成第几行第几列”。如果棋盘左上角留了边距margin格子大小为cell那么列号是(e-x() - margin cell / 2) / cell这里必须加cell/2做四舍五入否则点在第5列格子最左边除以cell的结果可能落到第4列。这个细节看着小但实际玩起来会感觉“点不准”尤其在窄格子上。void BoardWidget::mousePressEvent(QMouseEvent *e) { const int cell 60; const int margin 20; int col (e-x() - margin cell / 2) / cell; int row (e-y() - margin cell / 2) / cell; if (row 0 || row Board::SIZE || col 0 || col Board::SIZE) return; emit cellClicked(row, col); }坐标换算的公式看起来简单但很多人会在“该减margin还是该加margin”上犹豫。记住一条线鼠标点距离窗口左边是e-x()棋盘第0列的像素位置是margin所以相对坐标是e-x() - margin。把这个相对坐标除以cell再取整得到的是0到7之间的格子下标。加cell/2是为了让“点在第0列和第1列交界线附近”时偏向更近的那一格避免用户在格子边缘点击时产生挫败感。绘制棋盘本体时我习惯用一个统一的paintEvent函数把所有内容按优先级画出来背景色、网格线、可落子提示点、已有棋子。这样的好处是任何一次update()都会完整重绘不需要自己管理哪块脏了需要重画代码也更直白。void BoardWidget::paintEvent(QPaintEvent *) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing); const int cell 60; const int margin 20; painter.fillRect(margin, margin, Board::SIZE * cell, Board::SIZE * cell, QColor(0, 128, 0)); for (int i 0; i Board::SIZE; i) { painter.drawLine(margin i * cell, margin, margin i * cell, margin Board::SIZE * cell); painter.drawLine(margin, margin i * cell, margin Board::SIZE * cell, margin i * cell); } // 画当前玩家可落子的小圆点提示 painter.setBrush(QColor(255, 255, 255, 40)); painter.setPen(Qt::NoPen); for (int r 0; r Board::SIZE; r) for (int c 0; c Board::SIZE; c) if (board.isLegal(r, c, currentPlayer)) painter.drawEllipse( QPointF(margin c * cell cell / 2.0, margin r * cell cell / 2.0), 6, 6); // 画棋子 for (int r 0; r Board::SIZE; r) { for (int c 0; c Board::SIZE; c) { if (board.grid[r][c] 0) continue; QRectF disc(margin c * cell 4, margin r * cell 4, cell - 8, cell - 8); if (board.grid[r][c] 1) painter.setBrush(Qt::black); else painter.setBrush(Qt::white); painter.setPen(Qt::darkGray); painter.drawEllipse(disc); } } }注意绘制顺序可落子提示点必须在已有棋子之前画否则被棋子盖住。提示点用半透明白色既不明显干扰棋盘又能向玩家传递“这里可以下”的信息。棋子半径比格子稍小一圈留4像素边距看起来更自然也避免相邻棋子之间出现视觉粘连。3.2 用信号槽把规则层的结果“推”回界面层QPainter只是把数据画出来真正让程序动起来的是信号槽。我的BoardWidget定义了一个cellClicked(int row, int col)信号鼠标点击后发射。主窗口收到信号调用Board的isLegal和placeDisc再让BoardWidget调用setBoard更新内部数据并触发update()。这样数据更新和界面刷新就形成了一个单向流动不会出现“在paintEvent里调placeDisc”这种奇怪写法。// MainWindow 构造函数里 connect(boardWidget, BoardWidget::cellClicked, this, MainWindow::onCellClicked); void MainWindow::onCellClicked(int row, int col) { // 当前玩家非法落子直接忽略 if (!board.isLegal(row, col, currentPlayer)) return; board.placeDisc(row, col, currentPlayer); // 切换玩家若对方无棋可走则再切回来 currentPlayer -currentPlayer; if (!board.hasAnyLegalMove(currentPlayer)) currentPlayer -currentPlayer; boardWidget-setCurrentPlayer(currentPlayer); boardWidget-setBoard(board); boardWidget-update(); // 终局判断棋盘满或双方都无合法落子 if (board.isFull() || (!board.hasAnyLegalMove(1) !board.hasAnyLegalMove(-1))) { int black board.countDiscs(1); int white board.countDiscs(-1); // 弹窗或显示在状态栏 } }这里最容易被忽视的是“落子后立刻切玩家”和“判断对方有没有棋”的顺序。如果先判断再切那么当对方无棋可走时你仍然把回合切到对方界面就会卡在一个“该谁走都不清楚”的状态。正确顺序是先切到对方再检查对方是否无棋可走无棋可走就再切回来并继续判断这次是不是真的终局。setBoard之后要不要手动调用update()取决于setBoard内部是否已经触发了重绘。我习惯在setBoard里只保存数据不调用update()把刷新动作显式放在槽函数最后这样逻辑清晰也方便以后加动画。另一种常见做法是在setBoard末尾强制调用update()两种都可以但一定要保持一致否则会出现“落子后棋盘不变要晃动窗口才刷新”的尴尬。3.3 界面的三个常见手感问题残影、点不动、关闭时崩溃开发Qt黑白棋界面时遇到过三个高频问题都属于现象明显但原因分散的坑。第一个是棋盘上出现残影落子后旧棋子还在。原因通常是paintEvent里只画了新棋子却没有把整个棋盘背景重新填充。解决方法是像上面代码一样每次paintEvent第一件事就是fillRect铺底色再画网格线和棋子。只要背景色先覆盖整个棋盘区就不会有残留。第二个是“点不准”或“点了没反应”除了坐标换算问题还要检查是不是窗口的mousePressEvent被某个子控件拦截了。比如你在棋盘上叠了一个QLabel显示提示文字鼠标点上去时事件发给了QLabel而不是BoardWidget。解决方法是给所有挡在棋盘上方的控件设置setAttribute(Qt::WA_TransparentForMouseEvents)或者干脆不用子控件用paintEvent画文字。第三个是关闭窗口时程序崩溃常见于计时器还没停止、或者某个对象被double delete。黑白棋一般不需要计时器但如果加了“思考中”动画定时器必须在析构函数里停掉。还有一点如果MainWindow和BoardWidget都在栈上创建在事件循环退出时可能销毁顺序不对建议都作为MainWindow的子对象或指针管理。4. 人机对战不玄学权重表加两层搜索足够打败大多数第一次玩的人黑白棋源码包值不值很大程度上看有没有人机对战。如果只支持双人轮流内容量会小很多。但人机AI也不要一上来就写α-β剪枝先把最基础的贪心算法跑通再加深一层搜索效果已经足够应付课程设计和日常演示。4.1 先写一个能用的贪心AI从每个合法落点的局面估值里挑最大贪心AI的思路是遍历当前玩家所有合法落子位置把每个位置落子后的棋面用评估函数打分选分值最高的那个位置。黑白棋的评估函数没有唯一答案最经典的做法是一张8×8的权重表角落权重最高靠近角落的次高位权重最低因为落在这里容易被对手反过来利用。int evaluateBoard(const Board b, int player) { const int weight[Board::SIZE][Board::SIZE] { {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 Board::SIZE; r) for (int c 0; c Board::SIZE; c) { if (b.grid[r][c] player) score weight[r][c]; else if (b.grid[r][c] -player) score - weight[r][c]; } return score; }这组权重几乎是黑白棋AI的“通用起手式”四角100因为它永远不会被翻转角边邻格-20甚至-50因为下在这里会提前给对手提供角位切入机会中心区域分值低但下子多属于双方的争夺地带。这张表不需要绝对精确它比“只数棋子数量”强得多。如果一开始就用棋子数量做评估AI会非常喜欢占边结果把角送给对手。QPoint bestMoveFor(const Board b, int player) { QPoint best(-1, -1); int bestScore INT_MIN; for (int r 0; r Board::SIZE; r) { for (int c 0; c Board::SIZE; c) { if (!b.isLegal(r, c, player)) continue; Board next b; next.placeDisc(r, c, player); int s evaluateBoard(next, player); if (s bestScore) { bestScore s; best QPoint(r, c); } } } return best; }每次评估都要复制一个Board对象然后placeDisc改变副本再计算分值。这个复制成本在8×8棋盘上可以忽略8×864个int复制一次不过几百字节。贪心AI的下棋速度肉眼不可感知几乎瞬时完成。如果你担心性能可以把int s改成长期变量复用但代码会更绕我建议先保持清晰。4.2 给AI加两层搜索最小最大两步棋的代价与收益贪心AI的致命弱点是只看一步无法识别“这个位置虽然现在能多吃几个子但落完对手就能占角”。解决方法是加入一层极小极大搜索AI模拟自己走一步之后再模拟对手走一步然后评估这个两步后的局面。自己的层选最大值对手的层选最小值这就是两层搜索的基本形态。int minimax(Board b, int depth, int player) { if (depth 0 || b.isFull()) return evaluateBoard(b, player); int best INT_MIN; bool moved false; for (int r 0; r Board::SIZE; r) { for (int c 0; c Board::SIZE; c) { if (!b.isLegal(r, c, player)) continue; moved true; Board next b; next.placeDisc(r, c, player); // 下一层由对手选择他会让局面变得更差 int val -minimax(next, depth - 1, -player); if (val best) best val; } } // 当前玩家无棋可走跳过后让对手继续 if (!moved) return -minimax(b, depth - 1, -player); return best; }这里的技巧是省掉了“区分最大值层和最小值层”的麻烦每次递归都对结果取负天然形成“我的分越高对手的分就越低”的对抗关系。比如AI当前局面得分是5递归到对手一层时对手选一个让局面得分变成-5的方案再返回到AI这一层取负就变成5AI还是能拿到当时的最优选择。如果你用if (depth % 2 0)判断是max还是min逻辑一样但写法容易出错尤其在跳过回合时。外层调用方式QPoint aiMove(const Board b, int player, int depth) { QPoint best(-1, -1); int bestVal INT_MIN; for (int r 0; r Board::SIZE; r) { for (int c 0; c Board::SIZE; c) { if (!b.isLegal(r, c, player)) continue; Board next b; next.placeDisc(r, c, player); int val -minimax(next, depth - 1, -player); if (val bestVal) { bestVal val; best QPoint(r, c); } } } return best; }depth传2时AI会模拟“我走一步对手走一步”后的局面。depth传3就是“我-对手-我”三层搜索时间大约会翻倍到几十毫秒仍然可以接受。黑白棋搜索不像象棋那么夸张因为每步合法落子通常只有5到15个候选三层搜索的计算量完全在QWidget的承受范围内UI不会卡死。四层以上我一般不推荐在当前这种简单实现里用耗时可能到一两秒而且需要加剪枝才划算。4.3 把AI的思考时间和可观测性都管好超时控制与落子耗时显示写完AI之后还有一个很容易被忽略的点AI计算应该在主线程还是子线程执行。对于depth2或3直接在槽函数里同步调用没问题因为计算时间很短。但如果你把depth调到4或5主线程就会被占住窗口卡成“无响应”给用户的体感非常差。常见做法是保持depth2或3不做异步同时在界面状态栏显示“AI思考中”和“落子耗时”。void MainWindow::onAITurn() { QElapsedTimer timer; timer.start(); QPoint move aiMove(board, aiPlayer, aiDepth); int elapsed timer.elapsed(); statusBar()-showMessage(QString(AI落子 行%1 列%2耗时%3ms) .arg(move.x()).arg(move.y()).arg(elapsed)); if (move.x() 0) return; board.placeDisc(move.x(), move.y(), aiPlayer); currentPlayer -currentPlayer; boardWidget-setBoard(board); boardWidget-update(); }QElapsedTimer是Qt里常用的耗时统计工具elapsed()返回毫秒数。用QString::arg把整数转成字符串显示这比QString::number在拼多个变量时更顺手。如果你的AI策略参数需要热调比如深度、权重表是否启用行动力评估我建议把它们做成成员变量而不是散落在全局函数里方便在界面上加两个SpinBox实时修改。5. 拿到源码先别急着点运行Qt版本、MSVC套件与5个高频踩坑排查很多从网上下载的黑白棋源码包代码本身没问题卡在编译环境。这一章几乎每个坑都来自我实际帮人排查项目的经历。Qt项目能不能跑起来第一看Kit选得对不对第二看pro文件里的路径是不是被写死了。5.1 Qt版本与构建套件的对应关系不要拿MinGW的工程去配MSVC的Kit打开Qt Creator左侧的“项目 → 构建套件(Build Kit)”你会看到类似“Desktop Qt 5.15.2 MSVC2019 64bit”的名字。这个名字前半部分是Qt版本和编译器后半部分是编译器的具体版本。如果源码包是用MinGW编出来的你却选了MSVC2019套件轻则编译报一堆头文件路径错误重则链接阶段直接失败因为MinGW生成的.a库文件和MSVC的.lib文件互相不认识。几种常见的Qt版本与编译器搭配我列在下面方便你在Kit里对照。Qt版本编译器构建套件示例典型场景5.12.xMinGW 7.3Desktop Qt 5.12.2 MinGW 64-bit老项目、轻量课程设计5.15.2MSVC2019Qt 5.15.2 MSVC2019 64bit与VS2019配合最多5.15.2MinGW 8.1Qt 5.15.2 MinGW 64-bit不想装Visual Studio时6.xMSVC2022Qt 6.5.3 MSVC2019_64新项目、VS2022用户判断源码包用哪个套件不用瞎猜。先打开.pro文件看里面有没有greaterThan(QT_MAJOR_VERSION, 4)这类版本判断再看有没有win32-g或win32-msvc的条件分支。有的.pro文件会直接写QMAKE_CXXFLAGS /utf-8这就是MSVC专用参数MinGW会报unknown option。如果没有条件分支那就看压缩包里有没有.pro.user文件Qt Creator的.user文件里记录了原作者最近一次使用的Kit名称。如果手上只有Qt 5.15.2的MinGW包但源码是MSVC工程最省事的办法是重新创建一个空白Qt Widgets工程把所有.cpp和.h都加进去.pro文件按新环境重新生成。反正黑白棋这种规模的项目自己重搭pro比排查原始pro里的绝对路径快得多。5.2 报错“:-1: error: dependent ......\qt\5.15.2\msvc2019_64\include\qtwidgets”的排查这是典型的Qt Creator解析pro文件时报的错现象是在“编译输出”窗口里出现一行:-1: error: dependent ..\..\..\..\..\..\qt\5.15.2\msvc2019_64\include\qtwidgets does not exist我第一次见到这个报错时也很懵因为它不是编译器报的而是qmake在解析依赖时发现“相对路径指向的头文件目录不存在”。原因多半是pro或pri文件里写了类似INCLUDEPATH ../../../../qt/5.15.2/msvc2019_64/include/qtwidgets这样的硬编码相对路径。这个相对路径从原作者的目录层级计算出来一旦压缩包被解压到不同深度的文件夹里路径就失效了。解决方法是两层。第一层在Qt Creator左下角切换到“项目”模式找到“构建环境”查看QTDIR变量指向哪里。如果QTDIR已经是本机正确的Qt路径那么直接把pro文件里所有INCLUDEPATH、DEPENDPATH手动指向Qt模块的路径删掉让qmake自动从QTDIR推导。第二层如果pro文件里还有LIBS .../qtwidgets.lib之类的内容也要一并改成QT widgets这种模块声明方式不要手工写库路径。这个报错还有一个隐藏触发点项目文件夹路径本身包含中文或空格时qmake对相对路径的解析会变得非常敏感建议把项目解压到纯英文路径下。判断是不是这条路径的问题可以打开“工具 → 选项 → Kits → Qt Versions”看当前Kit对应的qmake路径是否真实存在于磁盘上。如果列表里有多个Qt版本而项目报错的那条路径指向的是5.15.2你的Kit却用的是6.x也会出现类似问题。最简单有效的做法是强制切换一次Kit比如从MSVC2019切到MinGW再切回来让Qt Creator重新生成.pro.user文件很多“没头没尾”的依赖错误能靠这招清掉。5.3 中文乱码、硬编码中文字符串和源文件编码黑白棋界面通常会有“开始游戏”“轮到黑棋”“胜利”等中文文案。源码压缩包如果是从Windows中文系统拷出来的很可能是GBK编码而Qt Creator默认按UTF-8读取源文件。一旦编码不匹配所有中文字符串在界面上显示成乱码最典型的是“锟斤拷”“鐢ㄦ埛”这种替换符组合。解决分两步。第一步把所有.cpp和.h文件用VS Code或Notepad统一转换成UTF-8。注意用“带BOM的UTF-8”在MSVC下最保险因为MSVC在没有BOM时会把源文件按本地代码页解析导致QString::fromUtf8(中文)里的字符串字面量已经被错误解码。第二步写代码时不要用裸的const char*赋给QString用QString::fromLocal8Bit(开始)或QStringLiteral(开始)让Qt自己处理编码转换。这里最省心的习惯是源文件一律UTF-8窗口标题和按钮文字统一走tr()或QStringLiteral。还有一个隐蔽问题如果你的代码用QString(中文)在Qt5里QString会按fromUtf8解析字符串字面量但前提是编译器的__FILE__和字符串常量已经是UTF-8。MSVC以前默认不是UTF-8所以从Qt5.6开始推荐在pro文件里加一行QMAKE_CXXFLAGS /utf-8这个参数让MSVC把源文件按UTF-8读取。如果加了/utf-8后反而乱码那就是源文件其实还是GBK需要先转换文件编码而不是只改参数。5.4 套件选对了还是崩高DPI模糊、退出崩溃和随机“首次运行失败”最后一条坑很玄但它确实高频。现象是程序在别人机器上正常换到你的电脑上要么界面字体发虚、要么启动后白屏、要么一关窗口就崩溃。先说模糊Qt5在Windows高DPI屏幕上不做适配需要main函数里手动开启#include QApplication int main(int argc, char *argv[]) { #if QT_VERSION QT_VERSION_CHECK(6, 0, 0) QApplication::setAttribute(Qt::AA_EnableHighDpiScaling); QApplication::setAttribute(Qt::AA_UseHighDpiPixmaps); #endif QApplication app(argc, argv); // ... }这两个Attribute必须在QApplication构造之前设置否则无效。如果你拿到源码包发现main函数里是QApplication app(argc, argv);在最前面就把它调整到后面。退出崩溃则要重点排查“重复连接信号槽”和“父子对象生命周期”两个点。比如onCellClicked里又调用了boardWidget-setBoard而setBoard内部又emit了一个信号回到MainWindow形成无限循环关窗口时对象正在销毁信号槽还在触发就会崩在QMetaObject::activate。排查方法是在析构函数里加一行qDebug() destroyed看崩溃前有没有打印。如果没打印就崩了说明对象已经提前被释放典型原因是MainWindow的成员变量声明顺序错误——析构函数按声明逆序释放而某个槽函数里还在访问已经释放的成员。~MainWindow() { // 如果AI线程在跑先请求停止并等待 if (aiThread) aiThread-quit(); delete aiThread; }只要涉及线程和定时器析构顺序就要特别小心。黑白棋项目通常不必用线程只要把AI深度控制在3以内就不会触发这类崩溃。真要用线程记住一个简单规则所有跨线程的信号槽连接接收对象必须活着否则槽函数不会执行也不会崩溃但会有“点了没反应”的假象。6. 验证黑白棋源码的检查清单从一个能跑的程序到一个能改的工程这一章的技巧很具体如何确认“这个源码不是一盘散沙”而是真的值得投入时间往里加功能。黑白棋这类项目有天然的验证优势——规则简单、状态有限可以写自动化测试来兜底。6.1 用“空局面指定落子”验证规则层规则层的验证不要靠肉眼一局局试而是写一个独立于界面的小程序。新建一个test_board.cpp把Board.cpp加进去然后在main函数里构造一些已知局面手动断言结果。比如清空棋盘、在(3,3)放黑棋、在(3,4)放白棋然后验证黑棋在(3,5)落子是否能翻转(3,4)。这种用例跑一遍比人玩十局更靠谱也是排查未初始化数组卫生的捷径。void test_single_flip() { Board b; std::memset(b.grid, 0, sizeof(b.grid)); b.grid[3][3] 1; // 黑 b.grid[3][4] -1; // 白 Q_ASSERT(b.isLegal(3, 5, 1)); b.placeDisc(3, 5, 1); Q_ASSERT(b.grid[3][4] 1); // 白子被翻成黑子 }Q_ASSERT在Release模式下会被编译掉建议用if (!condition) return 1的方式写测试。真正有用的测试是“模拟一次完整对局”用一个循环对弈棋盘直到终局然后断言黑白子总数等于64。能通过这个测试说明isLegal和placeDisc在交叉验证上没有致命缺陷。6.2 用自动对局验证AI统计胜率和耗时而不是看它“会不会玩”AI有没有进步不要靠手感要让它自己和自己下几局。给evaluateBoard和aiMove单独写一个run_ai_game函数黑AI用贪心白AI用两层搜索跑100局统计白方胜率。如果两层搜索不如贪心八成是权重表或搜索方向逻辑有误这时回头检查minimax里的取负是不是写反了。我平时拿到这类源码包做验证时一定会控制一个变量棋盘更新后是否有界面动画。没有动画时落子即刷新规则问题暴露得最明显。有动画时翻转过程会掩盖底层数据的错误让人误以为“只是动画没播完”。所以我的习惯是拿到“基于Qt的黑白棋游戏源码.zip”后先把它拆成三层看第一层是Board的纯规则代码第二层是BoardWidget的绘制代码第三层是AI和界面交互代码。任何一层有问题都不会被下一层的噪声带偏。这个分层检查的习惯是之前连续排查三个异常项目之后总结出来的希望帮到你。本文还有配套的精品资源点击获取
返回列表