ARTICLE DETAIL

资讯详情

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

Qt黑白棋源码解析:棋盘模型与QPainter自绘实战

Qt黑白棋源码解析:棋盘模型与QPainter自绘实战 简介基于Qt跨平台框架的黑白棋游戏完整工程源码包适合想要上手Qt桌面开发或图形界面游戏编程的初学者也方便有经验者快速复用棋类游戏框架。项目实现了黑白棋的完整规则流程包括轮流落子、翻转棋子、棋盘状态更新、胜负判定等核心逻辑并配有界面设计、交互响应和棋盘资源文件代码大量使用QWidget、信号槽等机制能帮助理解事件分发、控件布局和面向对象设计。压缩包共30个文件以cpp源码、h头文件、png/jpg界面图片和pro/ui/qrc工程配置为主另有可执行程序与多张界面截图整体大小仅1.4MB结构清晰便于逐模块研读。目前已有235人学习下载适合结合源码分析二维数组棋盘表示与胜负判断算法同时可作为扩展开发的基础模板也适合课程设计或毕业设计参考。1. 基于 Qt 的黑白棋游戏源码先看版本再看棋盘模型基于 Qt 的黑白棋游戏源码名字听着就是一个现成的翻转棋工程能编译能玩。但我拆完这个压缩包后想先说一句拿到手别急着点运行先确认两件事——代码依赖的 Qt 版本以及棋盘数据模型是二维数组还是对象数组。这两点直接决定你能不能顺利编译。这类老源码最常见的翻车点就是头文件与链接库版本不一致一编译就报 fatal: cannot mix incompatible Qt library 的错跟游戏逻辑一点关系都没有。这个工程用 C 与 Qt Widgets 实现了黑白棋的完整规则交替落子、8 方向夹子翻转、胜负判定同时附带资源文件、运行截图和流程设计图。适合正在学 Qt 的读者拆着学也适合想快速搭棋盘类原型的人直接改。压缩包里那个“基于qt的谷歌地图.zip”属于打包时混进来的无关文件可以忽略。2. 工程结构拆解.pro 怎么配、棋盘数据该放哪个类拿到一个 Qt 工程源码包我习惯先不打开任何 .cpp而是先看目录结构。这个工程的组织方式很典型一个 .pro 工程文件、若干 .h/.cpp 源码文件、资源文件与截图外加一个与主题无关的“基于qt的谷歌地图.zip”。先把这个结构读明白后面改代码才不会改到不对的地方。2.1 先读 .pro 工程文件确认 Qt 版本与模块依赖.pro 文件是 qmake 工程的身份证。双击 .pro 用 Qt Creator 打开时界面会要求你选择 Kit也就是 Qt 版本和编译器的组合如果用命令行则是用 qmake 先生成 Makefile。决定整个项目能不能跑起来的就是文件里那几行关键配置。QT core gui widgets TARGET Othello TEMPLATE app SOURCES main.cpp \ gameboard.cpp \ gamewidget.cpp HEADERS gameboard.h \ gamewidget.hQT core gui widgets声明了项目依赖的模块。Qt 5 里 widgets 必须显式写漏掉它所有用到 QWidget 的地方都会报“找不到头文件”Qt 6 虽然默认带了 core 和 guiwidgets 也依然要单独声明。TARGET Othello决定生成的可执行文件名TEMPLATE app表示这是个应用程序而不是库。如果换成TEMPLATE libqmake 会生成 .so 或 .dll很多新手在这里改错导致链接阶段翻车。命令行构建的流程通常是qmake Othello.pro make -j4第一步 qmake 根据 .pro 生成 Makefile第二步 make 才真正调用编译器。如果改了源码或 .pro 文件但编译结果没变化多半是 Makefile 没有重新生成最省事的处理是删掉 build 目录再重跑一次 qmake。这个习惯在排查各种 Qt 版本不匹配时特别管用尤其是“qtcreator对应qt版本”不对的老工程清理重来往往比细抠报错快得多。接下来看入口文件 main.cpp。Qt Widgets 程序的入口长这样#include QApplication #include gamewidget.h int main(int argc, char *argv[]) { QApplication a(argc, argv); GameWidget w; w.show(); return a.exec(); }QApplication 负责初始化 Qt 运行环境w.show() 把主窗口显示出来a.exec() 进入事件循环。事件循环是 Qt 程序能响应鼠标、键盘、定时器的基础它本质上是一个无限循环不断从事件队列里取出事件分发给对应对象。如果把 exec() 后面的代码理解为“程序要自己跑起来”那就错了——没有 exec()程序会直接退出窗口一闪而过。这段代码也暴露了项目的 Qt 时代特征#include QWidget这种写法在 Qt 5.12 到 Qt 6.x 都能编译但如果代码里出现 QRegExp、QTextCodec 这类 Qt 6 已移除的 API就需要做适配。稳妥做法是先装一个 Qt 5.15 LTS 把工程跑通再评估是否迁移到 Qt 6。现在网上讨论较多的“qt 5.15.2下载安装”基本就是这个路数选 LTS 版本不容易踩新版本兼容性的雷。2.2 棋盘模型用二维数组还是对象数组黑白棋的核心数据模型是那个 8x8 棋盘。源码里最常见的写法是二维数组// GameBoard.h class GameBoard { public: static const int SIZE 8; int board[SIZE][SIZE]; // 0空, 1黑, 2白 int cell(int row, int col) const { return board[row][col]; } void setCell(int row, int col, int val) { board[row][col] val; } };用int[SIZE][SIZE]承载棋盘状态0/1/2 分别表示空、黑子、白子。这是我个人最喜欢的方式原因有三个第一8x8 固定大小编译期分配内存既不用扩容也不存在动态分配失败的问题第二遍历和赋值非常直接调试时在监视窗口里能一眼看清整个棋盘第三后面写合法落子判定和翻转算法时全部操作都是“读数组、写数组”不用绕到对象和方法里。很多新手倾向于做一个ChessPiece类给每个棋子存坐标、颜色、动画状态再用QVectorChessPiece存棋子对象。这种设计在带帧动画的游戏里没问题但黑白棋不是这个场景——棋子位置是离散的棋盘状态每下一步要整体更新用对象数组会让搜索、翻转、清空棋盘变得很绕。更常见的问题是对象数组里存了 QGraphicsEllipseItem 之类的视图对象导致模型和视图耦合在一起后期加 AI 或做存档根本无从下手。我的建议很明确模型层只保留二维数组和操作棋盘的纯 C 方法视图层再去画棋子。职责分清楚之后做控制台测试、写单元测试、加 AI 都会顺畅很多。序列化存档也简单——棋盘数组是连续内存按行写入 QDataStream 即可恢复时再读回二维数组不会出现对象指针失效的问题。2.3 资源文件、截图与包里那个“谷歌地图.zip”压缩包里带了界面截图和流程图片。截图的作用有两个一是让你在编译之前就直观看到最终界面长什么样棋盘配色、棋子样式、信息栏布局都能对照二是验收标准如果自己改完样式运行效果和截图偏差很大说明资源加载出了问题。资源文件如果走 qrc 资源系统需要在 .pro 中声明RESOURCES res.qrcqrc 本身是 XML 格式把图片声明进去RCC qresource prefix/ fileimages/board.png/file fileimages/black.png/file fileimages/white.png/file /qresource /RCC代码里用:/images/board.png这种路径加载优点是不依赖运行时的工作目录。如果源码不是用 qrc 而是用相对路径读图从不同目录启动就会出现“棋子显示不出来、棋盘空白”的怪现象这跟代码逻辑无关纯粹是资源路径的坑。我接手类似源码时会先搜一下代码里是:/开头还是相对路径心里先有个底。至于“基于qt的谷歌地图.zip”从命名和工程内容看是打包时误放进去的无关文件不参与编译源代码也没有调用它。遇到这种情况不用纠结忽略即可。倒是在清理时别误删了 res.qrc 引用的图片目录否则编译能过运行后棋盘一片空白。3. 核心玩法实现合法落子判定与翻转算法的 8 方向搜索黑白棋的规则核心用一句话说就是落子后沿任意方向夹住对方至少一颗棋子就能把中间这些子全部翻过来。代码实现的难点在“任意方向”四个字。很多人第一次写黑白棋只处理了水平和垂直方向斜线不翻下一盘棋就发现棋子数量对不上。这章把三个关键函数拆开讲透。3.1 合法落子判定方向数组与边界检查判定一个位置能否落子要检查八个方向每个方向的逻辑完全一致所以用一个方向数组统一驱动循环// 八个方向的行列增量右、左、下、上、右下、右上、左下、左上 const int DIRS[8][2] { {0, 1}, {0, -1}, {1, 0}, {-1, 0}, {1, 1}, {-1, 1}, {1, -1}, {-1, -1} }; bool GameBoard::isValidMove(int row, int col, int player) const { if (row 0 || row SIZE || col 0 || col SIZE) return false; if (board[row][col] ! EMPTY) return false; int opponent (player BLACK) ? WHITE : BLACK; for (int i 0; i 8; i) { int r row DIRS[i][0]; int c col DIRS[i][1]; if (!inBoard(r, c) || board[r][c] ! opponent) continue; // 顺着这个方向一直往前直到越界或遇到非对方棋子 while (inBoard(r, c) board[r][c] opponent) { r DIRS[i][0]; c DIRS[i][1]; } // 越界说明这个方向没夹住遇到自己的棋子说明可以落子 if (inBoard(r, c) board[r][c] player) return true; } return false; } bool GameBoard::inBoard(int row, int col) const { return row 0 row SIZE col 0 col SIZE; }DIRS 数组里的每个元素是一对行列增量0 表示该方向不变1 或 -1 表示增大或减小。isValidMove的前置条件是落点为空然后沿八个方向寻找“对方棋子连续段”被当前落子和已有己方棋子夹住。while循环里先判断inBoard再访问board[r][c]这个顺序不能反否则会访问越界内存。在 Qt 里越界访问通常表现为不崩溃但行为诡异比如某些方向判定偶尔失效、程序不稳定定位起来比直接段错误难受得多。参数player用 1 或 2 表示黑方白方函数内部通过(player BLACK) ? WHITE : BLACK得到对手编号。这段代码的核心思想是“读棋盘、不改棋盘”所以标记为 const这也方便后面 AI 遍历所有合法落子时反复调用而不污染状态。3.2 落子与翻转先收集路径再统一更新判定合法之后真正的落子函数要做两件事放下当前棋子翻转所有被夹住的对手棋子。一个常见的错误是在搜索方向上边找边翻结果前一个方向的翻转改变了棋盘中间态后一个方向的判断读到了错误数据。正确做法是先收集待翻转位置再统一执行void GameBoard::applyMove(int row, int col, int player) { board[row][col] player; // 先落子 int opponent (player BLACK) ? WHITE : BLACK; for (int i 0; i 8; i) { // 收集这个方向上所有会被翻转的棋子位置 QVectorQPoint flips; int r row DIRS[i][0]; int c col DIRS[i][1]; while (inBoard(r, c) board[r][c] opponent) { flips.append(QPoint(r, c)); r DIRS[i][0]; c DIRS[i][1]; } // 只有这个方向终点是自己的棋子才真正执行翻转 if (inBoard(r, c) board[r][c] player) { for (const QPoint p : flips) { board[p.x()][p.y()] player; } } } }QPoint 的 x 存的是行号、y 存的是列号这个约定在整个工程里要保持一致混用就会导致翻转位置沿对角线偏移。applyMove里其实没有再次校验合法性这是刻意为之——界面层和 AI 在调用之前先用isValidMove过滤模型层保持接口简洁。但如果你要封装成公共接口在函数开头补一行合法性检查会更安全代价是每次落子多遍历一次棋盘在 8x8 规模下完全可忽略。这个函数还隐含了一个参数约定row和col是棋盘数组下标不是像素坐标。界面层负责把鼠标点击换算成行列值再传到模型层。如果你在视图里直接拿着像素坐标算翻转后面加窗口缩放时就会全面崩盘。补充一个后续 AI 要用的接口统计某个落点能翻转多少颗棋子。它的实现与applyMove高度相似只是把“执行翻转”改成“计数累加”函数保持 const。第 6 章那个贪心 AI 就是基于这个计数函数选点的。3.3 胜负判断与“无子可下”特例黑白棋的终局判定不是“谁先下完 64 格”而是棋盘满或双方都无合法落子最后数棋子。少了后者就会出现“棋盘还有空位但双方都无法落子”的死局bool GameBoard::hasValidMove(int player) const { for (int r 0; r SIZE; r) { for (int c 0; c SIZE; c) { if (isValidMove(r, c, player)) return true; } } return false; } int GameBoard::winner() const { int black 0, white 0; for (int r 0; r SIZE; r) { for (int c 0; c SIZE; c) { if (board[r][c] BLACK) black; else if (board[r][c] WHITE) white; } } if (black white) return BLACK; if (white black) return WHITE; return EMPTY; // 平局 }hasValidMove把 64 个格子全部扫一遍只要有一个合法落子就返回 true。它的调用时机值得注意每方落子后都要先查对方是否有合法落子如果对方没有当前玩家继续下也就是“Pass 规则”只有双方都无子可下时才触发终局。很多人写黑白棋会忽略这个特例造成“明明还有棋可下却被判负”的误判。winner()用两层循环统计黑子白子数量返回 1、2 或 0平局。严格来说终局时黑子数和白子数之和不一定等于 64所以统计不能基于总步数推演。这三个函数组成了黑白棋逻辑层的最小闭环isValidMove 判定可落子applyMove 执行翻转hasValidMove 判断游戏是否继续winner 结算胜负。后续不管是用 Qt 做界面、用控制台做测试、还是写 AI都只依赖这几个接口。4. Qt 交互层实战QPainter 自绘棋盘与鼠标坐标换算逻辑层完工后界面层就能完全基于 GameBoard 来画了。Qt 里画棋盘有三种常见做法QLabel 贴图、QGraphicsView 加图元、QWidget 重写 paintEvent 自绘。我推荐自绘原因是棋盘状态变化频繁每次落子只需要调用一次 update() 重绘不用维护一堆图元对象的增删改。4.1 为什么用 QPainter 自绘而不是图片贴图三种做法的取舍实现方式优点缺点QLabel 贴图代码少适合静态展示状态变化时更新麻烦无法灵活绘制提示点QGraphicsView 图元支持动画、拾取、碰撞检测模型和视图容易耦合维护成本偏高QWidget paintEvent 自绘灵活直观性能足够需要手动处理坐标换算自绘核心代码在 paintEvent 里每次 update() 被调用时执行void GameWidget::paintEvent(QPaintEvent *) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); // 画棋盘背景 painter.fillRect(rect(), QColor(0, 128, 0)); // 画网格线8x8 格子从 margin 开始算 for (int i 0; i GameBoard::SIZE; i) { painter.drawLine(margin i * cellSize, margin, margin i * cellSize, margin GameBoard::SIZE * cellSize); painter.drawLine(margin, margin i * cellSize, margin GameBoard::SIZE * cellSize, margin i * cellSize); } // 画棋子遍历棋盘数组只画有棋子的格子 for (int r 0; r GameBoard::SIZE; r) { for (int c 0; c GameBoard::SIZE; c) { int val board-cell(r, c); if (val EMPTY) continue; QPointF center(margin c * cellSize cellSize / 2.0, margin r * cellSize cellSize / 2.0); painter.setPen(QPen(QColor(80, 80, 80), 1)); painter.setBrush(val BLACK ? Qt::black : Qt::white); painter.drawEllipse(center, cellSize * 0.4, cellSize * 0.4); } } }setRenderHint(QPainter::Antialiasing, true)打开抗锯齿否则圆形棋子边缘会有明显锯齿。fillRect(rect(), QColor(0, 128, 0))把整个窗口铺成墨绿色棋盘背景想换配色直接改这个颜色。margin和cellSize是成员变量比如固定 margin24窗口宽度 560 时cellSize (560 - 2 * 24) / 8 64。画棋子时用QPointF定位圆心再用drawEllipse(center, radius, radius)画圆不用手动算左上角坐标。注意绘制函数里直接读了board-cell(r, c)而不是访问界面层的成员——这是刻意保持模型封装棋盘数据只有 GameBoard 自己能改视图只读不写。窗口尺寸变化时需要在 resizeEvent 里重新计算 cellSize否则窗口拉大后棋盘不会跟着缩放。4.2 鼠标点击换算成棋盘坐标几个容易错的地方鼠标点击是 Qt 事件驱动编程里最典型的例子。关键点在于把窗口像素坐标换算成棋盘数组下标void GameWidget::mousePressEvent(QMouseEvent *event) { if (!gameRunning) return; // 用 event-pos()不要用 event-globalPos() QPoint p event-pos(); int col (p.x() - margin) / cellSize; int row (p.y() - margin) / cellSize; if (row 0 || row GameBoard::SIZE) return; if (col 0 || col GameBoard::SIZE) return; emit cellClicked(row, col); event-accept(); }三个容易踩的坑。第一必须用event-pos()而不是event-globalPos()前者是相对当前窗口的坐标后者是屏幕坐标窗口只要不在屏幕原点用 globalPos 换算必然整体偏移。第二要先减 margin 再除 cellSize棋盘不是从窗口 (0,0) 开始的左上角留白要扣掉。第三越界判断不能省棋盘外点击不该有任何反应否则点窗口边缘可能触发非法落子。高分屏下还有第四个坑系统缩放比例不是 100% 时event-pos()返回的是物理像素而绘制用的是逻辑像素。Qt 5 需要在 main() 里启用QApplication::setAttribute(Qt::AA_EnableHighDpiScaling)Qt 6 默认启用不要重复设置。如果你在代码里自己做了缩放换算又开了系统级缩放会出现双重换算点击位置依然错位。反方向的换算是“棋盘坐标到像素坐标”像素 X margin col * cellSize画网格、画棋子、判断点击要共用同一套公式。我的习惯是封装pixelToCell和cellToPixel两个函数界面里任何位置都不直接写裸公式这样将来调整边距或格子大小时只改一处。4.3 落子后的刷新流程与当前玩家轮转界面只是入口真正的棋盘更新逻辑交给一个控制类。用信号槽把“玩家动作”和“界面刷新”解耦void GameController::onCellClicked(int row, int col) { if (m_board-cell(row, col) ! EMPTY) return; if (!m_board-isValidMove(row, col, m_currentPlayer)) return; m_board-applyMove(row, col, m_currentPlayer); int opponent (m_currentPlayer BLACK) ? WHITE : BLACK; if (m_board-hasValidMove(opponent)) { m_currentPlayer opponent; } else if (!m_board-hasValidMove(m_currentPlayer)) { emit gameOver(m_board-winner()); return; } // 对方没有合法落子当前玩家继续下相当于“跳过回合” emit boardChanged(); }这段逻辑的先后顺序是关键先判断对手有没有合法落子再判断自己。如果倒过来会出现“对手还有子可下却提前判我赢”的翻车。gameOver信号携带 winner 值视图层收到后弹提示框并禁用棋盘。boardChanged信号触发 GameWidget 里的槽槽内调用update()QWidget 会在事件循环中安排一次重绘。信号槽机制是 Qt 事件驱动编程的核心。每个继承 QObject 的类声明Q_OBJECT宏后就能定义 signals 和 slots。带 Q_OBJECT 的类必须经过 moc 处理qmake 会自动完成如果你手改文件结构或者漏了 Q_OBJECT 宏编译时会报“未定义的 vtable”错误这个报错经常让新手摸不着头脑。排错时先在 .pro 里确认 HEADERS 包含了对应头文件其余交给构建系统处理。5. 编译运行的避坑记录版本不匹配、平台插件与坐标偏移这个工程本身不大但把它跑起来、改顺畅绕不开几个 Qt 开发里高频出现的坑。每一条都是我在实际工程里踩过或者帮别人排查过的按“现象、原因、解决”记录下来。5.1 编译报错 fatal: cannot mix incompatible Qt library (version ex50601)现象编译时报fatal: cannot mix incompatible Qt library (version ex50601) with this library或者编译过了但一运行就退出。原因这段报错里的 ex50601 对应 Qt 5.6.1。它表示“include 进去的 Qt 头文件版本”和“链接的 Qt 库版本”不一致。最常见的是系统里同时装了多个 Qtqmake 生成 Makefile 时用了 A 版本的头文件路径链接器却找到了 B 版本的库。另一个常见场景是 Qt Creator 的 Kit 里选择的 Qt 版本和编译器不匹配尤其是“qtcreator对应qt版本”选错时编出来的程序从根上就是混搭的。解决先检查 Qt Creator 左下角 Kit 选择的 Qt 版本再删除 build 目录确保从头生成 Makefile。命令行构建时把 PATH 指到目标 Qt 的 bin 目录再用绝对路径调用 qmake。如果是从源码自行编译 Qt要保证 configure 和 make 用的是同一套编译器中间换了编译器版本也会引发这个错。遇到这类错误别去改源码问题几乎都出在环境层面。5.2 运行报错 could not find the qt platform plugin linuxfb现象在 ARM 开发板或没有桌面的 Linux 系统上运行编译好的程序报qt.qpa.plugin: could not find the qt platform plugin linuxfb程序直接退出。桌面 Linux 上偶尔也会碰到。原因Qt 的 GUI 程序启动时要加载平台插件Linux 下没有 X11 时用的是 linuxfb 插件。报错说明 Qt 安装目录的plugins/platforms/下没有libqlinuxfb.so或者构建 Qt 时没有编译这个插件。这和代码逻辑完全无关纯粹是运行环境缺少组件。解决安装对应的平台插件。桌面 Linux 上优先用 xcb 而不是 linuxfb嵌入式环境则需要重新编译 Qt配置时加上 linuxfb 支持。也可以设置环境变量QT_QPA_PLATFORM_PLUGIN_PATH指向插件目录临时绕过。如果程序要在开发板上发布最好把整个 Qt 运行库一起带过去而不是只拷贝可执行文件。“qt发布软件”时最容易漏的就是 plugins 目录这点和报错本质是一回事。5.3 高分屏或窗口缩放后点击错位现象在 2K/4K 显示器上运行绘制的棋盘位置和鼠标点击命中位置对不上点这个格子落到了隔壁格子里。原因绘制时用的是逻辑坐标而鼠标事件返回的坐标在高分屏缩放下可能带有 device pixel ratio。更隐蔽的情况是代码里自己做了 DPI 缩放同时 Qt 又在系统层面做了一次双重缩放导致坐标偏移。解决Qt 5 在 main() 开头统一启用QApplication::setAttribute(Qt::AA_EnableHighDpiScaling)Qt 6 默认启用不需要重复设置。坐标换算全部走pixelToCell一个函数在这个函数里处理 devicePixelRatioF 换算不要在多个地方各算一遍。检查办法很简单把窗口拖到不同显示器上点击四个角和中心位置看命中是否准确。5.4 翻转逻辑的经典顺序错误边找边翻现象落子后有一部分棋子没有翻转或者不该翻转的棋子被翻过去了棋盘上的黑白分布和预期不一致。原因在判定合法后直接用一个循环边遍历边翻转。前一个方向的翻转改变了棋盘中间状态后一个方向的判断读到了已经被修改的数据导致误翻或漏翻。解决严格遵守“先收集、后翻转”的流程。applyMove 里先用 QVector 收集每个方向上所有待翻转位置全部方向扫描结束后再统一翻转。这个顺序问题在 8x8 棋盘上不容易通过肉眼发现因为偶尔翻错一两颗子不会让程序崩溃但 AI 对局时错误会不断累积最终比分完全失真。我在给 AI 写自动对局测试时抓出过这种问题那之后所有棋盘落子函数都强制走两阶段。5.5 QPainter 状态污染save/restore 不成对现象程序运行一段时间后出现绘制异常比如棋子边缘出现奇怪颜色的描边界面控件渐变效果错乱严重时直接 qt 崩溃。原因paintEvent 里调用了painter.save()和painter.restore()但两者不成对。save 会把当前画笔状态压栈restore 会弹栈恢复。如果 save 了忘记 restore后续绘制代码继承了一个被修改的画笔或画刷状态画出来的东西就完全不对了。解决养成对称的习惯——save 和 restore 成对出现或者干脆不调用这两个函数直接设置 painter 的属性。如果只是临时改个画刷颜色用大括号包住保证 restore 一定执行。这个习惯养成了Qt 绘图基本不会因为画笔状态问题崩溃。6. 玩法验证与扩展建议从自动对局到简单 AI 落子逻辑和界面都跑通之后下一步不是急着加功能而是先验证逻辑正确性。黑白棋的翻转规则方向多、分支多靠手工试几盘很难覆盖全部情况。6.1 用自动对局验证游戏逻辑我的做法是写一个自动对局脚本让两个随机玩家在控制台里轮流调用 isValidMove 和 applyMove一方无合法落子就自动跳过双方都无子可下时结算比分。跑 200 局如果中间出现卡死、非法落子或最终棋盘棋子数与落子步数对不上说明逻辑有 bug。顺便统计每一步的合法落子数如果某一步合法落子数是 0但程序没有判定无子可下那问题一定出在 hasValidMove 的遍历逻辑里。验证坐标换算则用另一招遍历 64 个格子调用 cellToPixel 再调用 pixelToCell检查结果是否等于原始行列值。这种成对测试能快速暴露边距和缩放问题。6.2 加一个贪心 AI翻转数最多的位置优先黑白棋 AI 可以从贪心算法起步。思路很简单遍历当前玩家的全部合法落子统计每个落子能翻转多少颗棋子选翻转最多的位置QPoint AIPlayer::nextMove(GameBoard *board, int player) { QVectorQPoint moves board-validMoves(player); if (moves.isEmpty()) return QPoint(-1, -1); int bestScore 0; QPoint best moves.first(); for (const QPoint m : moves) { int flips board-countFlips(m.x(), m.y(), player); if (flips bestScore) { bestScore flips; best m; } } return best; }这个函数依赖两个接口validMoves()返回所有合法落子countFlips()统计某个位置的翻转数量。countFlips的实现与 applyMove 几乎一样只是不改棋盘数组。想加强 AI可以在评分里加入角点权重落子在角上得 100 分落在角周围扣 50 分——黑白棋角点一旦占据就几乎无法被翻转这条规律比单纯吃子数量更重要。接入 AI 时把当前玩家的落子逻辑替换成 AI 计算再用 QTimer 单次触发落子避免在信号槽里做耗时计算阻塞界面。如果想做“人机对战”在轮转时判断当前玩家是不是 AI是就用 QTimer::singleShot 延迟一步再走这样视觉上更自然。再往上可以做存档功能棋盘数组用 QDataStream 写入本地文件下次启动时恢复即可。自从上次给一个棋盘工程加 AI 时翻转统计漏写了一个方向导致 AI 永远不走斜线之后我每次拿到黑白棋这类源码都会先写一个自动对局脚本跑 200 局再把 AI 接进去。这个习惯帮我躲掉了好多“看起来没问题、一跑就出幺蛾子”的坑。这个源码包解压后建议你也按这个顺序走一遍。希望帮到你。本文还有配套的精品资源点击获取
返回列表