恒美微站 Logo 恒美微站
  • 首页
  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心
  • 联系我们

黑白棋C++ Qt项目实战:规则引擎、界面交互与AI搜索实现

  • 首页
  • 资讯中心
  • /
  • 黑白棋C++ Qt项目实战:规则引擎、界面交互与AI搜索实现

相关资讯

Python游戏碰撞检测全攻略:从几何原理到性能优化 2026/10/11 16:08:02
基于fo-dicom的WinForm DICOM影像查看器开发实战 2026/10/11 16:08:02
图书管理系统数据库设计三要素:E-R图、数据流图与关系模式 2026/10/11 16:08:02

最新资讯

混凝土缺陷检测数据集:双格式标注与YOLOv8训练实战
揭秘driftwm磁吸聚簇:窗口如何自动组成可整体操控的Cluster
oh-my-pi robomp 的 PR 终态应答机制:`finalized_pr_comment` 的语义、路由与工作区回收设计
如何用AI Toolbox把AI CLI配置同步到WSL和远程服务器:SSH与WSL同步完整教程
非LVM根分区爆满怎么办?四步救急与迁移实战指南
彻底清除软件卸载残留:Windows原理与GEEK实操

今日推荐

UE动画修改实战:从资产编辑到重定向与蒙太奇驱动
统计随机数生成器攻击下的KLJN安全密钥交换协议Matlab仿真
政务API安全治理:资产测绘、低代码编排与行标对标实践

本周热门

UE动画修改实战:从资产编辑到重定向与蒙太奇驱动
统计随机数生成器攻击下的KLJN安全密钥交换协议Matlab仿真
政务API安全治理:资产测绘、低代码编排与行标对标实践

本月精选

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证
2026 大模型集体涨价:用 Python 做企业 Token 成本测算与选型避坑(附配置)

黑白棋C++ Qt项目实战:规则引擎、界面交互与AI搜索实现

发布时间:2026/10/11 16:08:02
黑白棋C++ Qt项目实战:规则引擎、界面交互与AI搜索实现 简介一款基于C与Qt实现的黑白棋翻转棋双人对弈程序源码适合正在学习C面向对象编程、Qt GUI开发或想完成课程设计的初学者。项目覆盖8x8棋盘二维数组表示、合法落子判断、棋子翻转与连锁反转、胜负判定等核心规则并借助QGraphicsView与QGraphicsItem构建可鼠标交互的棋局界面同时给出保存/加载游戏及AI对手如Alpha-Beta剪枝的设计思路。压缩包共28个文件约1.12MB主要包含7个cpp源码、4个h头文件、1个ui界面文件以及pro工程文件、Makefile和可直接运行的exe便于对照源码理解界面与逻辑的关联另有debug目录、数据库文件和预览图可辅助定位构建过程。已有362人学习整体结构清晰从数据结构、算法设计到事件响应均有涉及适合作为入门级棋类游戏项目的完整参考。1. 黑白棋 C Qt一个能跑通逻辑、界面和 AI 的小项目骨架黑白棋Reversi/Othello配合 C Qt是我见过最适合当“从语法到工程”过渡的棋类项目规则半小时能讲完但落子判定、方向扫描、搜索剪枝、界面坐标换算都能练到。网上很多 c 小游戏源码还是控制台输出这套黑白棋 Qt 版把界面、事件、搜索都带上是从控制台走向桌面程序的一条最短路径。纯 C 写规则引擎Qt 画棋盘并接管鼠标事件再补一个不笨的 AI。适合刚学 Qt 的 C 开发者也适合拿它做课程设计的人。下面按落地顺序走一遍每一步都能直接抄。2. 黑白棋规则引擎棋盘结构、八方向扫描与合法性判定怎么一次写对界面只是外观规则才是骨架。如果一上来就在 Qt 里画棋盘后面每加一个功能都要翻界面代码改起来很痛。我习惯先把棋盘和规则独立成一组纯 C 函数Qt 只负责显示和转发鼠标事件。这样规则引擎可以单独单元测试AI 搜索也能直接复用它。2.1 棋盘存储用二维数组还是位棋盘先按可读性选黑白棋是固定 8×8 棋盘存储方案基本就两种。第一种是二维数组用std::arraystd::arrayint, 8, 8表示0 空、1 黑、2 白调试时直接看内存值就能定位问题。第二种是位棋盘用两个uint64_t分别表示黑白子的分布AI 搜索时可以用位运算批量算出合法位置速度更快但代码读起来像天书新手很容易在移位和掩码上翻车。我的建议是先按可读性选二维数组把这套代码跑通等 AI 搜索深度不够用、真的需要性能优化的时候再换位棋盘。这里有一个额外的理由AI 搜索每一层都要复制局面std::array可以直接整体赋值而裸数组做不到。// 棋盘用 8x8 二维数组存储0空1黑2白 // 用 std::array 而不是裸数组方便整体拷贝AI 搜索时要频繁复制局面 using Board std::arraystd::arrayint, 8, 8; // 八个方向的坐标增量上、下、左、右、左上、右上、左下、右下 // 这个方向表贯穿合法判定和吃子翻转全篇只维护这一份 const int DIRS[8][2] { {-1, 0}, {1, 0}, {0, -1}, {0, 1}, {-1, -1}, {-1, 1}, {1, -1}, {1, 1} };这段代码没什么魔法但方向表是整个规则引擎的核心。用一个二维数组统一存八个方向后面写合法判定和翻转时只需要一个循环不用复制八段几乎一样的代码。std::array的另一个好处是它可以被const Board传递AI 搜索递归时传引用不会意外修改原局。2.2 合法落子判定与吃子翻转共用一套八方向循环黑白棋的规则一句话就能说清落子点必须为空从落子点向任意方向出发先遇到连续一段对方棋子再遇到己方棋子这段对方棋子才能被翻转。判定合法和翻转操作都基于同一个扫描逻辑所以我把它们写成两个函数让判定的那套循环在翻转里再走一遍。// player: 1黑 2白对手就是 3 - player bool isLegalMove(const Board board, int row, int col, int player) { if (board[row][col] ! 0) return false; // 非空位置不能落子 int opponent 3 - player; for (auto d : DIRS) { int r row d[0]; int c col d[1]; bool metOpponent false; // 沿着方向连续走只允许踩到对方棋子 while (r 0 r 8 c 0 c 8 board[r][c] opponent) { metOpponent true; r d[0]; c d[1]; } // 走出边界或遇到空格都不算必须是己方棋子才说明夹住 if (metOpponent r 0 r 8 c 0 c 8 board[r][c] player) { return true; } } return false; }边界检查写在 while 条件和收尾条件里保证数组不会越界。metOpponent这个标志很重要它确保中间至少有一个对方棋子否则紧邻一个己方棋子也被误判成合法。3 - player是黑白棋里常用的对手换算1 换 2、2 换 1不用再写 if。翻转函数是判定逻辑的延伸第一遍扫描确认哪个方向能夹住第二遍从夹住的终点往回走把中间的对方棋子全翻过来。void flipDiscs(Board board, int row, int col, int player) { int opponent 3 - player; for (auto d : DIRS) { int r row d[0]; int c col d[1]; // 第一遍沿方向扫描确认该方向是否夹住对方棋子 while (r 0 r 8 c 0 c 8 board[r][c] opponent) { r d[0]; c d[1]; } if (!(r 0 r 8 c 0 c 8 board[r][c] player)) { continue; // 该方向没夹住跳过 } // 第二遍从终点往回落子点方向回退把对方棋子翻成己方 r - d[0]; c - d[1]; while (board[r][c] opponent) { board[r][c] player; r - d[0]; c - d[1]; } } board[row][col] player; // 最后放上落子本身 }注意flipDiscs自己不检查合法性调用方必须先过isLegalMove否则第二遍回退时会翻转错误。我见过不少直接把两个函数揉在一起的写法看起来省事但 AI 搜索时需要先复制棋盘再尝试落子如果翻转里带着合法性判断搜索分支就没法快速剪枝了。分开写AI 那边才能自由地“试一步再回退”。2.3 Pass 和终局判断双方都无子可下才算结束黑白棋有个容易漏的规则一方无棋可走时自动跳过不换子由对方继续下只有双方都无合法落子游戏才结束。判断方法很直接遍历全棋盘查一遍。bool hasAnyLegalMove(const Board board, int player) { for (int r 0; r 8; r) { for (int c 0; c 8; c) { if (isLegalMove(board, r, c, player)) { return true; } } } return false; }这里的性能不用担心8×8 全遍历一次只有 64 个点即使 AI 搜索里反复调用也够快。真正的坑在于回合逻辑我一般写一个nextTurn()来处理 Pass用一个计数器记录连续 Pass 次数双方都 Pass 才进入终局。void countDiscs(const Board board, int black, int white) { black white 0; for (int r 0; r 8; r) { for (int c 0; c 8; c) { if (board[r][c] 1) black; else if (board[r][c] 2) white; } } }终局统计用引用参数带出黑白子数比返回一个打包后的结构体更直接调用方不用拆位。规则引擎到这里已经完整Qt 界面可以直接在这些函数之上搭。3. Qt 界面落地用 QGraphicsView 画棋盘把鼠标点击变成落子动手前先把环境定下来我一般用 Qt 5.15 或 5.14.2IDE 直接用 Qt Creator编译器选 MSVC 套件项目文件用 CMake。下面代码基于 Qt 5 C17切到 Qt 6 也基本不用改。界面这一层要解决三件事画出棋盘和棋子、把鼠标点击换算成行列号、把落子事件交给逻辑层处理。3.1 自绘还是 QGraphicsView有动画需求就直接上场景画棋盘的常见做法有两种。第一种是继承 QWidget 重写paintEvent用drawEllipse画棋子代码量小但后期做翻转动画、悔棋动画时每一步都要手动管理重绘区域。第二种是用 QGraphicsView QGraphicsScene把每个棋子当作一个 QGraphicsItemQt 会帮你处理碰撞、选中和属性动画。黑白棋这种项目我直接选 QGraphicsView理由很实际翻转棋子是黑白棋最直观的视觉反馈用QPropertyAnimation改 item 的 scale 属性就能做出压扁再弹起的效果比自己在 paintEvent 里算插值干净得多。QGraphicsView 的代价是要理解它的三层坐标体系viewport 坐标是窗口上的像素scene 坐标是场景里的逻辑坐标item 坐标是每个图形项内部的坐标。鼠标事件拿到的是 viewport 坐标必须先用mapToScene转成 scene 坐标再做棋盘格换算这一步漏掉就是最常见的错位翻车点。3.2 棋盘背景、网格线与坐标换算的最小实现我先画一个自定义的 BoardView把棋盘常量写在类里格子 60 像素四周留 30 像素边距整个棋盘场景尺寸是 540 像素。棋盘背景、网格线、星位点都在drawBackground里画棋子以后作为 item 加进 scene这样背景只画一次不随棋子动画频繁重绘。// BoardView 继承 QGraphicsViewscene 由它在构造时创建 class BoardView : public QGraphicsView { Q_OBJECT public: explicit BoardView(QWidget* parent nullptr); // 常量棋盘 8x8格子 60px四周留 30px 边距 static constexpr int CELL_SIZE 60; static constexpr int PADDING 30; static constexpr int BOARD_PIXEL PADDING * 2 CELL_SIZE * 8; // 540 signals: void cellClicked(int row, int col); // 把棋盘坐标交给控制器 protected: void drawBackground(QPainter* painter, const QRectF rect) override; void mousePressEvent(QMouseEvent* event) override; };如果你用的是 C11 或 C14static constexpr int成员还需要在类外补一句定义C17 开始才不需要。Qt 5.15 默认支持 C17但老项目可能还锁在 C14编译报未定义引用时先查这里。void BoardView::drawBackground(QPainter* painter, const QRectF rect) { // 棋盘底色深绿色接近实物棋盘 painter-fillRect(sceneRect(), QColor(#00695C)); // 网格线横竖各 9 条起点都是 PADDING painter-setPen(QPen(QColor(#E0E0E0), 1)); for (int i 0; i 8; i) { qreal y PADDING i * CELL_SIZE; painter-drawLine(QPointF(PADDING, y), QPointF(PADDING CELL_SIZE * 8, y)); qreal x PADDING i * CELL_SIZE; painter-drawLine(QPointF(x, PADDING), QPointF(x, PADDING CELL_SIZE * 8)); } // 星位点黑白棋棋盘在 2/5 交叉点有四个小圆点 painter-setBrush(Qt::white); for (int r : {2, 5}) { for (int c : {2, 5}) { painter-drawEllipse(QPointF(PADDING c * CELL_SIZE, PADDING r * CELL_SIZE), 3.0, 3.0); } } }这里有一个细节drawBackground的参数 rect 是待重绘区域如果拿它做整盘填充窗口局部更新时棋盘底色会花掉。我一律用sceneRect()做填充保证任何时候重绘的都是完整背景。网格线和星位点随后画在这层底色上棋子 item 盖在上面层级清晰。坐标换算是界面的核心函数负责把 scene 坐标映射到棋盘行列号。棋盘内容从 PADDING 处开始所以先减掉边距再除以格子大小。// 把 scene 坐标换算成棋盘行列号 // 返回 false 表示点在边距外不算落子 bool BoardView::pixelToBoard(const QPointF pos, int row, int col) const { qreal x pos.x() - PADDING; qreal y pos.y() - PADDING; if (x 0 || y 0) return false; col static_castint(x / CELL_SIZE); row static_castint(y / CELL_SIZE); return row 0 row 8 col 0 col 8; }除法取整后得到 0 到 7 的行列号落在边距内的点击会被x 0 || y 0拦掉右侧和下侧多出来的部分由row 8 col 8拦截。外部传入的 pos 必须是 scene 坐标而不是 viewport 坐标这个约定我会在注释里写清楚避免调用方拿鼠标事件里的event-pos()直接传进来。3.3 鼠标点击到落子的信号链路以及界面逻辑解耦鼠标按下时先做坐标映射再发射一个cellClicked信号。BoardView 只负责“用户点了哪里”不判断规则这样规则引擎改不动的时候界面代码一行都不用动。void BoardView::mousePressEvent(QMouseEvent* event) { // view 坐标先映射到 scene 坐标再交给 pixelToBoard QPointF scenePos mapToScene(event-pos()); int row, col; if (pixelToBoard(scenePos, row, col)) { emit cellClicked(row, col); // 只上报坐标不判断规则 } QGraphicsView::mousePressEvent(event); }信号链路的下一站是 GameController它持有棋盘状态、当前玩家以及一个指向 BoardView 的指针。连接方式用 Qt 5 新式语法这样信号和槽的签名不匹配在编译期就能发现。void GameController::onCellClicked(int row, int col) { if (!isLegalMove(m_board, row, col, m_currentPlayer)) { return; // 非法落子直接忽略界面可以通过别的途径给出提示 } flipDiscs(m_board, row, col, m_currentPlayer); refreshView(); // 重建或更新棋子 item 的状态 m_currentPlayer 3 - m_currentPlayer; if (!hasAnyLegalMove(m_board, m_currentPlayer)) { m_currentPlayer 3 - m_currentPlayer; // 自动 Pass } if (!hasAnyLegalMove(m_board, m_currentPlayer)) { gameOver(); // 双方都没棋统计终局 } }这段逻辑把上一章的函数串成了回合流程。注意 Pass 的处理顺序先切换玩家再查新玩家有没有棋没有才切回去切回去之后再查一次双方都无棋才终局。漏掉第二次检查会造成游戏卡在单方 Pass 的无限循环里。4. 给电脑配上脑子评估函数加 Alpha-Beta 搜索让人机对战不再抽风黑白棋的棋子数量比五子棋少但每步候选位置多纯贪心根本打不过会看两三步的人。人机对战最稳的方案是“评估函数 Alpha-Beta 剪枝搜索”先给每个局面打一个分数再在搜索树里向前看若干步把对手最坏的选择和己方最好的选择都算一遍。4.1 评估函数位置权重表为什么比单纯数子更靠谱黑白棋有个反直觉的特点中盘吃子多不代表赢因为把子下到角附近容易被对手占角而角一旦被占就永远翻不回来。所以评估函数不能只数黑白子数量常见做法是直接给每个位置一个权重角的权重最高角斜对角给负分边和中间位置给正分但不像角那么高。// 黑白棋常用权重表后四行按棋盘 180 度旋转对称 // 角是永久稳定子给 30角斜对角是陷阱点给负分 const int WEIGHT[8][8] { { 30, -15, 10, 10, 10, 10, -15, 30}, {-15, -25, 1, 1, 1, 1, -25, -15}, { 10, 1, 5, 5, 5, 5, 1, 10}, { 10, 1, 5, 3, 3, 5, 1, 10}, { 10, 1, 5, 3, 3, 5, 1, 10}, { 10, 1, 5, 5, 5, 5, 1, 10}, {-15, -25, 1, 1, 1, 1, -25, -15}, { 30, -15, 10, 10, 10, 10, -15, 30}, }; // 评估函数计算黑子总权重减白子总权重 int evaluate(const Board board, int rootPlayer) { int score 0; for (int r 0; r 8; r) { for (int c 0; c 8; c) { if (board[r][c] 1) score WEIGHT[r][c]; else if (board[r][c] 2) score - WEIGHT[r][c]; } } return rootPlayer 1 ? score : -score; }权重表本身不是秘密很多开源黑白棋程序都长这样关键是理解它的动机角权重 30 是为了让搜索算法宁可少翻几个子也要抢角角斜对角给 -15是因为在那个位置落子会送对方一个角。rootPlayer参数是为了让搜索树里的每一层都能站在固定视角算分外层传入当前人类玩家或者 AI 自己内部直接复用。4.2 Alpha-Beta 剪枝搜索深度设多少、边界怎么处理搜索部分我直接给出完整实现。核心是一个递归函数当前玩家轮到自己时选分数最大的分支对方回合选分数最小的分支。Alpha-Beta 剪枝的思想是如果我方已经找到一个至少 alpha 分的走法而对方在某条分支里能把分数压到比 alpha 还低那这条分支就不用再看下去了。#include algorithm #include vector #include utility #include limits const int INF std::numeric_limitsint::max(); // 搜索当前局面返回从 rootPlayer 视角看的分数 // alpha 初始为 -INFbeta 初始为 INF剪枝发生在 beta alpha int GameAI::search(Board board, int depth, int alpha, int beta, int player, int rootPlayer) { if (depth 0) { return evaluate(board, rootPlayer); } std::vectorstd::pairint, int moves getLegalMoves(board, player); if (moves.empty()) { // 自己没有棋对手也没有棋 - 终局直接评估 if (!hasAnyLegalMove(board, 3 - player)) { return evaluate(board, rootPlayer); } // 否则自动 Pass深度继续减但交换玩家 return search(board, depth - 1, alpha, beta, 3 - player, rootPlayer); } if (player rootPlayer) { // 我方节点取最大值 int best -INF; for (const auto m : moves) { Board next board; flipDiscs(next, m.first, m.second, player); // 落子并翻转 int val search(next, depth - 1, alpha, beta, 3 - player, rootPlayer); best std::max(best, val); alpha std::max(alpha, best); if (beta alpha) break; // beta 剪枝对手已经有更差的选择 } return best; } else { // 对方节点取最小值 int best INF; for (const auto m : moves) { Board next board; flipDiscs(next, m.first, m.second, player); int val search(next, depth - 1, alpha, beta, 3 - player, rootPlayer); best std::min(best, val); beta std::min(beta, best); if (beta alpha) break; // alpha 剪枝 } return best; } }每次进入新分支都复制一份棋盘std::array的拷贝开销很小这就是第二章坚持用它而不是裸数组的原因。深度建议从 4 开始配上这张权重表中端桌面机上单个落子的响应在几十毫秒到一百多毫秒体感跟手加到 6 之后计算量会明显上涨这时可以限制每层只搜索权重最高的 8 到 12 个候选落子而不是遍历全部。调用入口负责选择最佳落子对每一个合法位置模拟落子后进入搜索最终取分数最高的那个。std::pairint, int GameAI::bestMove(const Board board, int player) { auto moves getLegalMoves(board, player); if (moves.empty()) return {-1, -1}; int bestVal -INF; std::pairint, int best moves.front(); for (const auto m : moves) { Board next board; flipDiscs(next, m.first, m.second, player); int val search(next, 4, -INF, INF, 3 - player, player); if (val bestVal) { bestVal val; best m; } } return best; }搜索里面有一个容易忽略的边界当depth已经减到 0但当前玩家还有棋可走我们直接返回评估分数不再继续展开。这是刻意的搜索深度到了就截断否则无限递归。Pass 分支同样要消耗一层深度不然会出现“双方一直 Pass 但 depth 不减”的死循环。我测过几个深度组合深度 4 对深度 6只要权重表不变胜率会有明显差距但深度 6 的等待感也很明显。课程设计做到深度 4 加评估函数已经能打过完全乱下的新手再往上加移动限制才划算。5. 黑白棋 C Qt 开发避坑坐标换算、中文乱码与界面假死的排查记录这一章是实际开发里最常踩的五个坑按“现象、原因、解决”写清楚。前两个和坐标、线程有关后三个是 Qt 项目里逃不掉的经典问题每条我都踩过不止一次。5.1 坐标换算错位棋子画到棋盘外或点了没反应现象鼠标点击的位置和屏幕上棋子落下的位置差了一个 PADDING 的距离棋子跑到了网格线外面或者点击棋盘边缘没有反应。原因最常见的是在mousePressEvent里直接拿event-pos()当成 scene 坐标用减了 PADDING 之后本来就偏了还有一类是换算时忘了减 PADDING把边距也算进了格子。解决在鼠标事件里强制走一遍mapToScene然后再调pixelToBoard。我调试时会临时在点击处画一个半径 3 的小圆肉眼确认换算结果对得上再继续画棋子。这个小圆只放在调试分支里发布前删掉即可。5.2 界面假死AI 深搜跑在 UI 线程现象点击棋盘后整个窗口转圈几百毫秒甚至几秒内无法拖动、无法关闭松手后落子直接出现。原因bestMove里的递归搜索占用了主线程Qt 的事件循环被阻塞所有窗口消息都排着队等它返回。解决把 AI 计算挪到子线程保证 UI 线程只收结果。最省事的是QtConcurrent::run注意捕获棋盘快照而不是直接引用主线程的m_board否则数据竞争会造成玄学崩溃。// GameController 里发起 AI 落子 void GameController::requestAiMove() { Board snapshot m_board; // 子线程只读快照避免竞争 QtConcurrent::run([this, snapshot]() { auto move m_ai.bestMove(snapshot, m_currentPlayer); emit aiMoveReady(move.first, move.second); // 信号自动回到主线程 }); }如果你的 Qt 版本较老不想引 QtConcurrent也可以按 Qt 的常规做法用moveToThread把一个 AI Worker 对象挪进子线程同样是用信号把结果传回来。要点是子线程里绝不直接修改m_board所有状态变更都回主线程做。5.3 中文乱码MSVC 下 Qt 5 源码编码问题现象代码里写的“黑白棋”编译后显示成????或一堆乱码英文标题正常。原因MSVC 编译器默认按本地代码页GBK 或 CP936解析源文件而 Qt 5 的QString拿到字符串后按 UTF-8 解释两边对不上。Qt 6 修正了一部分但 Qt 5.14/5.15 都还在。解决给中文字符串统一加u8前缀或者直接用QStringLiteral它在编译期就把字符串转成 UTF-16不依赖源码文件编码。setWindowTitle(QStringLiteral(黑白棋));如果你用 CMake还可以在CMakeLists.txt里给 MSVC 加/utf-8编译选项一劳永逸。不过QStringLiteral是 Qt 5.14 之后的推荐写法两个方案搭着用最稳。5.4 信号槽连接失败却不报错现象点击棋盘什么反应都没有connect那一行看着没问题编译器也不报错。原因最常见的是用了旧式SIGNAL/SLOT宏信号名或槽函数名拼写不一致运行时才报“No such slot”或者接收对象已经被delete连接静默失效。解决用 Qt 5 新式语法让编译器参与签名检查。connect(m_view, BoardView::cellClicked, m_controller, GameController::onCellClicked);如果换了新式语法还是没触发我会在槽函数第一行加qDebug()打印确认信号是否真的发出。还有一种隐蔽情况用 lambda 连接时捕获了this的 lambda 在this析构后仍然存在事件循环触发时访问空指针这种问题用调试器看调用栈最好定位。5.5 Qt 崩溃数组越界和棋盘访问现象程序在 AI 搜索到一定深度后随机崩溃堆栈指向flipDiscs或evaluate有时候 debug 版不崩、release 版才崩。原因方向推进的 while 条件里只判断了棋子的值忘了先判断 r/c 是否在 0-7 内越界访问触发未定义行为表现就是时好时坏。解决所有 while 条件里先判边界再取数。我再加一个访问入口函数把边界检查集中起来调试期开Q_ASSERT发布版自动去掉。int at(const Board board, int r, int c) { Q_ASSERT(r 0 r 8 c 0 c 8); return board[r][c]; }把代码里所有board[r][c]替换成at(board, r, c)之后越界问题会在 debug 版第一现场暴露而不是等到 release 随机崩。这一步做完AI 搜索深度加到 6 甚至更高都不会再出这类问题。6. 收尾技巧悔棋、翻转动画和自动化验证棋类程序做到能下、能赢还不够悔棋和动画这两件事决定了一个小游戏项目看起来专不专业。最后再补一个验证思路让规则改动不再靠人肉点棋盘。6.1 用撤销栈实现悔棋注意退几步的问题黑白棋是双方交替落子人机对战中“悔棋”应该是撤回你自己刚走的那一步同时把对手的回应也撤掉。实现上一开始就要想到这一点不能只回退一层。我在 GameController 里维护一个历史栈每次落子前把当前局面压栈。void GameController::doMove(int row, int col) { m_history.push_back(m_board); // 落子前快照入栈 flipDiscs(m_board, row, col, m_currentPlayer); // 省略 Pass 和终局处理 } bool GameController::undo() { if (m_history.size() 2) return false; m_history.pop_back(); // 弹出对手刚走的局面 m_board m_history.back(); // 回到你落子前的局面 m_currentPlayer 3 - m_currentPlayer; emit boardChanged(m_board); return true; }历史栈里存的是完整棋盘快照不是“上一步走了什么”这样做最稳悔棋不依赖规则逻辑只需要把状态还原回去。注意空棋盘和游戏刚开始的几步要判断栈深度否则back()会读到未定义状态。6.2 翻转动画与最终验证翻转动画我用QPropertyAnimation改棋子的scale属性把 X 轴先缩到 0在压扁的瞬间换颜色再弹回 1视觉上就像棋子被翻了个面。// 翻转动画的最小实现把棋子的 X 轴缩放到 0换色后再展开 void FlipAnimation::start(QGraphicsEllipseItem* item, QColor target) { auto* anim new QPropertyAnimation(item, scale, this); anim-setDuration(180); anim-setStartValue(1.0); anim-setKeyValueAt(0.5, 0.0); // 中间时刻完全压扁 anim-setEndValue(1.0); connect(anim, QPropertyAnimation::finished, this, []() { item-setBrush(target); // 在压扁瞬间换颜色 anim-deleteLater(); }); anim-start(); }动画结束记得deleteLater否则每次都生成一个新的 QPropertyAnimation界面会越来越卡。这个方案不需要引入 QGraphicsItemAnimation 那套老 API也能做出同样的效果。验证方法我习惯直接写一个无界面的自动对战入口把两个 AI 分别设成黑和白循环下完十盘输出每步耗时和终局子数。这样改一个权重参数、调一次搜索深度立刻能看出胜负变化不用反复手动点棋盘。我自己的习惯是每加一个功能先跑一遍自动对战确认规则没被改坏再去动界面。这套黑白棋 C Qt 项目做到这里从规则到界面再到 AI 就完整了照着这个顺序重建一遍你踩的坑会比我最开始少很多。希望帮到你。本文还有配套的精品资源点击获取

关于恒美微站

恒美微站专注于为个体商户、工作室提供极简自助建站服务,让每个人都能轻松拥有专业网站。

快速链接

  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心

服务项目

  • 可视化建站
  • 拖拽编辑
  • 主题定制
  • SEO 优化
  • 网站托管

联系方式

  • 📍 地址:北京市朝阳区建国路 88 号
  • 📞 电话:400-888-8888
  • ✉️ 邮箱:info@hmyw.cn
  • 🕐 时间:周一至周日 9:00-18:00

© 2024 恒美微站 hmyw.cn 版权所有 | 京 ICP 备 12345678 号