中国象棋 Visual C++ 源码:MFC 界面与 Alpha-Beta 搜索
简介基于 Visual C 与 MFC 编写的中国象棋桌面程序面向已掌握 C 基础语法、想通过完整图形界面项目理解工程组织的学生与开发者补足棋类程序中规则建模、界面交互与搜索算法缺少范例的缺口。棋盘布局、车直行、炮隔子打吃、士象不出九宫等规则被拆成独立判断模块配合局面合法性校验与异常处理便于逐层阅读。程序提供人机、人人、机机三种对弈模式人机部分用 Alpha-Beta 剪枝与评估函数模拟对手走法人人模式演示按钮、对话框与事件驱动编程的写法机机对弈可用于算法调优另附八阵图、乌龙摆尾等残局棋谱。压缩包共 112 个文件、约 1.84MB以 27 个 .h 和 25 个 .cpp 源码为主干.rc、.res 资源脚本与 .ico、.bmp 位图组成界面素材.dsp、.dsw 工程文件与 .obj、.exe 产物方便直接对照运行。已有 221 人学习适合按模块拆读结构、复现棋规判断与 GUI 组织方式。1. 先别急着编译这份中国象棋 Visual C 程序到底是什么形态压缩包解开第一眼能看到的是.sln、若干.cpp/.h、一个.rc资源脚本和一堆.bmp。这是一份典型的中国象棋 Visual C 程序源码MFC 框架承载窗口与消息棋盘靠 GDI 在客户区一笔一笔画出来棋子是位图贴图走法判断、合法性校验和人机搜索全部写在本地 C 类里。它真正给出的价值是界面、规则、搜索三层可以拆开、可以下断点、可以单独替换的完整闭环而不是“从零写象棋”这种宏大命题。两类人用得上一类是拿它当课程设计底稿需要看清 MFC 文档视图、资源组织和消息映射怎么配合另一类是做过后端或其他语言想看看 10×9 棋盘的数据结构和 Alpha-Beta 在 C 里怎么落地。别急着点编译先花十分钟认清工程结构后面能省掉一半报错。2. 工程结构拆解棋盘坐标、位图资源与 MFC 消息入口拿到一份陌生的 MFC 工程上来就按 F5 是最费时间的做法。老工程最常见的失败点不在业务代码而在平台工具集、字符集和资源 ID 这三处它们全在工程文件和资源脚本里跟你写得好不好没关系。先把文件分层看清楚再决定用哪个配置编译顺序反了就是反复重定向、反复清缓存。2.1 三层文件工程配置、资源脚本、业务代码一个能跑的中国象棋 MFC 工程文件大致分三摊各摊的职责和排查顺序是不一样的层典型文件/目录作用出问题时先看什么工程配置.sln、.vcxproj、.vcxproj.filters决定平台工具集、字符集、MFC 静态/动态链接平台工具集版本、字符集是否 Unicode资源.rc、resource.h、res/*.bmp菜单、图标、对话框模板、棋盘与棋子位图资源 ID 是否与代码里的宏对得上业务*.cpp/*.h窗口消息、棋盘绘制、走法生成、搜索类之间的调用关系而不是逐个函数读.vcxproj.filters只影响 IDE 里的目录树显示删了不影响编译但删了以后所有文件会平铺在一个节点下读代码时很难受建议保留。resource.h是自动生成的头文件里面的IDB_BITMAP1这类宏是代码和.rc之间的唯一契约手工改 ID 时要两边一起改只改一边编译能过、运行时资源加载返回空句柄界面就是一片灰。判断工程老不老看两处就够了工程属性里“平台工具集”是不是还挂着一个当前机器没装的版本字符集是“使用多字节字符集”还是“使用 Unicode 字符集”。这两项决定了你打开工程后要改多少东西。2.2 棋盘坐标9 列 10 行怎么换算成像素中国象棋是 9 条竖线、10 条横线的交叉点落子也就是 9 列 10 行。工程里几乎所有坐标 bug 都出在“数组下标”和“像素位置”之间那次换算上数组是[row][col]屏幕是(x, y)中间还隔着棋盘左上角的起始像素和格子宽高。把这两个函数单独写成一对后面所有点击、绘制、动画都调它们不要在消息处理函数里到处写x / 40这种魔法数字。// 棋盘参数集中定义避免散落在各个消息处理函数里 const int ROWS 10; // 10 条横线 const int COLS 9; // 9 条竖线 const int CELL_W 56; // 相邻竖线的像素间距 const int CELL_H 56; // 相邻横线的像素间距 const int ORIGIN_X 40; // 左上角第一个交点的客户区 x const int ORIGIN_Y 40; // 左上角第一个交点的客户区 y // 数组下标 - 客户区像素交点即棋子中心 CPoint BoardToPixel(int row, int col) { CPoint pt; pt.x ORIGIN_X col * CELL_W; pt.y ORIGIN_Y row * CELL_H; return pt; } // 客户区像素 - 数组下标四舍五入到最近交点越界返回 false bool PixelToBoard(CPoint pt, int row, int col) { col (pt.x - ORIGIN_X CELL_W / 2) / CELL_W; row (pt.y - ORIGIN_Y CELL_H / 2) / CELL_H; if (col 0 || col COLS || row 0 || row ROWS) return false; return true; }这两个函数看着简单参数含义要抠清楚。ORIGIN_X/ORIGIN_Y是第一个交点的像素位置不是棋盘外框的左上角很多工程在这里差半个格子表现是“点棋子正中心没反应点右下角一点反而选中了”。 CELL_W / 2是整数除法下的四舍五入作用是让点击落在交点附近任意位置都能吸附到最近的交叉点手感好不好全靠这一项。CELL_W和CELL_H在标准棋盘上通常相等但分开定义更稳改成非等比缩放时不用再翻代码。如果要支持窗口缩放这两个常量就不能是编译期常量得改成成员变量在OnSize里按客户区宽高重新算水平方向留出左右边距后除以 89 个交点之间是 8 段垂直方向除以 910 个交点之间是 9 段取两者较小值保证棋盘不变形再把剩余空间均分给边距。这套算法写一次就够别在每个绘制函数里重算。2.3 消息入口OnPaint、OnLButtonDown 与双缓冲MFC 里跟棋盘相关的消息入口通常只有三个OnPaint负责画OnLButtonDown负责选子和落子OnInitDialog或OnInitialUpdate负责加载位图、初始化棋盘数组。读代码时按这三个入口往下追比按类名逐个打开效率高得多。OnPaint里最容易出的问题是闪烁。直接对CPaintDC一张一张贴棋子位图每次重绘都能看见棋盘先刷白再画出棋子的过程鼠标一动就闪。标准做法是内存双缓冲建一个兼容 DC 和兼容位图所有绘制先在内存位图上完成最后一次BitBlt到屏幕。void CChessView::OnPaint() { CPaintDC dc(this); CRect rc; GetClientRect(rc); // 1. 建内存 DC 与兼容位图尺寸与客户区一致 CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, rc.Width(), rc.Height()); CBitmap* pOldBmp memDC.SelectObject(bmp); // 2. 所有绘制都画在 memDC 上背景、棋盘线、棋子、选中框 DrawBackground(memDC, rc); DrawBoardLines(memDC); DrawPieces(memDC); DrawSelection(memDC); // 3. 一次性贴到屏幕再把 GDI 对象恢复回去 dc.BitBlt(0, 0, rc.Width(), rc.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }这段代码的关键不在双缓冲本身而在最后那行SelectObject(pOldBmp)。CBitmap是局部对象析构时会尝试删除底层位图句柄如果它还挂在memDC里没被换出来删除就会失败短时间看不出问题连续重绘几千次之后 GDI 句柄数会顶到进程上限界面直接卡死。memDC是栈对象析构时自动DeleteDC这一点不用管但位图必须手动换回。OnLButtonDown里只做三件事把点击像素转成数组下标、判断该位置有没有当前一方的棋子、有的话记录选中并把旧选中清掉。落子判定不要写在这里单独放到TryMove(from, to)里这样鼠标操作和后面的人机对弈、悔棋回放可以共用同一套规则入口。3. 走法生成与合法性校验七种棋子怎么写成可测的函数规则层是整个工程里最值得花钱读的部分因为它是纯逻辑不依赖界面可以单独拿出来写测试。判断一份中国象棋源码质量高低看走法生成函数的组织方式基本就有数了好的写法是“每种棋子一个函数、统一返回伪合法走法、再由一层过滤器统一做自将检测”差的写法是在鼠标消息里直接写一堆 if改一条规则要动五个地方。3.1 棋盘表示为什么是 10×9 定长数组棋盘表示常见三种二维定长数组、一维数组、位棋盘。这份工程用的是最朴素的int board[10][9]从工程可读性和调试友好度看这个选择是对的。二维数组的索引与棋盘的行列一一对应在调试器里展开就能肉眼看出局面贴一张棋盘图对照着找 bug比任何日志都快。一维数组省一点寻址代价是你每次都要在脑子里做r * 9 c的换算位棋盘在引擎里性能最好但可读性差不适合作为教学或二次开发的底稿。棋子编码建议用“红正黑负”或“红 1–7、黑 8–14”两段式判断阵营只看一个范围不用额外维护一个颜色数组编码棋子编码棋子1帅8将2仕9士3相10象4马11马5车12车6炮13炮7兵14卒0空判断阵营写成IsRed(p) { return p 1 p 7; }、IsBlack(p) { return p 8 p 14; }比p 0 p 8这种裸比较可读得多。编码一旦定下来就不要中途改因为位图资源的下标、棋谱记录的字符映射、评估函数的子力价值表全都依赖它改一次要同步三处。3.2 七类棋子的伪合法走法伪合法的意思是只按棋子本身的运动规则生成不考虑走完之后自己是否被将军。这样拆分的好处是每个函数都很短出错时容易定位。车和炮共用一条直线扫描逻辑区别只在“吃子前是否允许越过一个子”马要判马腿象要判象眼和是否过河士和将限制在九宫内兵和卒要区分过河前后。// 车四个方向直线延伸遇到第一个子停止空格可走敌方子可吃 void GenerateRook(int r, int c, int side, std::vectorMove out) { static const int dr[4] { -1, 1, 0, 0 }; static const int dc[4] { 0, 0, -1, 1 }; for (int d 0; d 4; d) { int nr r dr[d], nc c dc[d]; while (InBoard(nr, nc)) { int target m_board[nr][nc]; if (target 0) { out.push_back(Move(r, c, nr, nc)); } else { if (SideOf(target) ! side) out.push_back(Move(r, c, nr, nc)); break; // 无论敌我碰到第一个子就停 } nr dr[d]; nc dc[d]; } } } // 炮空格可走吃子时必须且只能隔一个“炮架” void GenerateCannon(int r, int c, int side, std::vectorMove out) { static const int dr[4] { -1, 1, 0, 0 }; static const int dc[4] { 0, 0, -1, 1 }; for (int d 0; d 4; d) { int nr r dr[d], nc c dc[d]; bool jumped false; // 是否已经越过炮架 while (InBoard(nr, nc)) { int target m_board[nr][nc]; if (!jumped) { if (target 0) out.push_back(Move(r, c, nr, nc)); else jumped true; // 第一个子当炮架不吃 } else { if (target ! 0) { if (SideOf(target) ! side) out.push_back(Move(r, c, nr, nc)); break; // 炮架后只吃第一个子 } } nr dr[d]; nc dc[d]; } } }GenerateRook里的break是易错点很多人写成吃子后继续扫描结果车能穿过对方棋子吃后面的子测试时不容易发现因为正常对局很少有人把两个子排成一条线给你吃。GenerateCannon的jumped标志把炮的两种行为拆得很清楚比“数中间有几个子”的写法少一层循环。马的走法要单独判马腿目标点是(r2, c1)时马腿在(r1, c)目标点是(r1, c2)时马腿在(r, c1)。八个方向对应八个马腿位置写成两张静态表最省事。象眼同理走(r2, c2)时象眼在(r1, c1)另外加一条“象不过河”的判断红象的row必须大于等于 5黑象必须小于等于 4。士和将的九宫限制是列 3 到 5红方行 7 到 9黑方行 0 到 2。这些边界如果写反表现是棋子能走出九宫肉眼一看就知道反而好查。3.3 合法性过滤试走、自将检测、将帅照面伪合法走法生成完之后统一走一层过滤把每个走法在棋盘上试走一步检查己方将帅是否处于被攻击状态是就丢弃。这个做法慢但正确性最容易保证深度 4 层以内的搜索完全撑得住。追求性能的写法是维护“被攻击格”增量信息不过那是引擎优化阶段的事第一版不要碰。// 统一过滤试走一步若己方将帅被攻击或双方照面则该走法非法 std::vectorMove CChessBoard::GenerateLegalMoves(int side) { std::vectorMove pseudo, legal; GeneratePseudoMoves(side, pseudo); // 上一节的七类生成函数汇总 for (size_t i 0; i pseudo.size(); i) { int captured MakeMove(pseudo[i]); // 返回被吃子编码0 表示空走 bool bad IsKingAttacked(side) || KingsFacing(); UnmakeMove(pseudo[i], captured); // 必须还原包括被吃掉的子 if (!bad) legal.push_back(pseudo[i]); } return legal; }MakeMove和UnmakeMove必须严格配对MakeMove返回被吃子编码UnmakeMove用它还原。血泪经验是只要有一个分支提前return而没调UnmakeMove棋盘就会永久多一个子或少一个子症状是“下着下着突然多出来一个车”而且很难复现。稳妥写法是在MakeMove之后立刻用一个 RAII 小结构包住析构时自动还原异常和提前返回都不会漏。将帅照面是独立于“被攻击”的另一条规则必须单独判// 双方将帅在同一列且中间无任何棋子即为照面当前走法非法 bool CChessBoard::KingsFacing() const { int r1 -1, c1 -1, r2 -1, c2 -1; for (int r 0; r ROWS; r) for (int c 0; c COLS; c) { if (m_board[r][c] 1) { r1 r; c1 c; } else if (m_board[r][c] 8) { r2 r; c2 c; } } if (r1 0 || r2 0 || c1 ! c2) return false; // 一方已被吃或不同列 int lo (r1 r2 ? r1 : r2), hi (r1 r2 ? r2 : r1); for (int r lo 1; r hi; r) if (m_board[r][c1] ! 0) return false; // 中间有子挡住不照面 return true; }这段代码每生成一个走法就全盘扫一遍看着很浪费但只有 90 个格子实际开销可以忽略。真要优化在MakeMove/UnmakeMove里维护两个将帅的坐标即可别在第一版就做。3.4 将死与困毙无合法走法不等于和棋合法走法列表为空在当前一方的视角下就是输棋不管是被将死还是困毙。中国象棋规则里困毙判负这一点跟国际象棋的“逼和”不一样很多人照搬国际象棋的写法返回 0 分表示和棋结果残局阶段 AI 会主动把自己走到无子可动看起来像故意送死。搜索里返回分值要带层数补偿否则引擎会“拖延”被将死的时间// 无合法走法返回负的将死分减去当前层数让引擎选择更晚被将死的分支 if (legal.empty()) return -MATE_SCORE (MAX_DEPTH - depth);MATE_SCORE取一个远大于任何评估值的大数比如 100000。减(MAX_DEPTH - depth)的含义是越早被将死扣得越多引擎在几条必败分支里会选拖得最久的那条。不加这一项AI 在必败局面下会随便走看起来很傻。4. 人机对弈Alpha-Beta 搜索与评估函数怎么调界面和规则都能跑之后人机对弈是第二个要读透的模块。这部分代码量通常不大但参数敏感同一个搜索框架评估函数差一点、走法排序差一点棋力能差出好几个档次。先确认搜索框架对不对再谈评估顺序反了会一直在调权重永远调不明白。4.1 负极大值框架与 Alpha-Beta 裁剪负极大值的好处是搜索函数不分“最大层”和“最小层”每一层都取当前视角的最大值子层返回时取负。代码只有一份递归调用不用判断该取 max 还是 min出错概率低很多。// 负极大值 Alpha-Beta返回当前走子方的视角分值 int CChessEngine::Search(int depth, int alpha, int beta, Move best) { if (depth 0) return Evaluate(m_side); // 叶子节点静态评估 std::vectorMove moves m_board.GenerateLegalMoves(m_side); if (moves.empty()) return -MATE_SCORE (MAX_DEPTH - depth); OrderMoves(moves); // 吃子、将军优先提升剪枝率 int bestScore -INF; for (size_t i 0; i moves.size(); i) { int captured m_board.MakeMove(moves[i]); m_side -m_side; // 换边 Move dummy; int score -Search(depth - 1, -beta, -alpha, dummy); m_side -m_side; // 换回来 m_board.UnmakeMove(moves[i], captured); if (score bestScore) { bestScore score; best moves[i]; } if (score alpha) alpha score; if (alpha beta) break; // 剪枝 } return bestScore; }参数里有三处容易写错。-Search(depth - 1, -beta, -alpha, dummy)里的窗口要取负并交换写成-Search(depth-1, alpha, beta, dummy)就退化成普通极小极大剪枝全失效搜索同样深度耗时多好几倍。m_side的换边和换回必须紧贴递归调用中间不能有return否则换边状态会污染兄弟节点。alpha beta的剪枝放在更新alpha之后顺序反了会漏掉最优解。OrderMoves是性价比最高的一处优化。同样深度下先搜吃子走法和将军走法剪枝率能明显提升实现只需要按“被吃子价值 - 走子价值”排个序。这一步不做深度 5 可能要等十几秒做了以后通常能压到可以接受的范围。4.2 评估函数子力价值加位置权重评估函数决定引擎“喜欢什么样的局面”。最朴素的版本只算子力车 900、马 400、炮 450、兵 100 这种量级能下但很呆兵到底线不动、马往边角跑、车缩在自己半场。加位置权重表之后棋力提升非常明显。棋子基础子力位置权重处理方式车900中路和对方半场加分开局不宜过早出动马400边角扣分靠近中路加分有根被保护加分炮450中路和对方半场加分空头炮额外加分兵/卒100过河加分越靠近对方九宫加分越多仕/士、相/象200固定分值离开防守位扣分实现上就是两张int[10][9]的位置表红黑各一张或者黑方用红方表上下翻转叶子节点遍历 90 个格子累加。表里的数值要反复对弈调整没有理论最优只能靠实战喂。常见错误是把位置分加得比子力分还大结果 AI 为了抢一个好位置白白送车看到这种棋不用怀疑一定是权重比例失衡。评估函数里还可以加一条“将帅安全”统计将帅周围被对方攻击的格子数给一个小惩罚。这项对残局帮助明显但要注意别和搜索里的将死判定重复计分。4.3 搜索深度、节点数与响应时间深度每加一层节点数大约涨 5 到 8 倍Alpha-Beta 剪枝理想情况下能把它压到平方根量级但实际受走法排序质量影响很大。工程里通常给三档难度深度 2 对应“简单”深度 4 对应“普通”深度 6 对应“困难”同时给一个时间上限超时就返回当前最优。迭代加深是更稳的做法从深度 1 开始逐层加深每层记录最优走法下一层优先搜这个走法。好处是任何时刻中断都有一份完整结果不会出现“想了十秒还没走出第一步”的尴尬。实现只需要在外面套一层循环把上一层的best放到OrderMoves的最前面。还有一个隐藏开销GenerateLegalMoves里每个走法都要MakeMove再判自将深度 5 时这个开销占比不小。如果发现 AI 思考时间远超预期先看这里再考虑评估函数。可以在搜索内部用伪合法走法加“走完后自己将帅是否被攻击”的即时判断跳过完整合法性过滤能省掉一部分重复试走。5. 编译与运行排查字符集、资源丢失、GDI 泄漏这几处最容易翻车老 MFC 工程跑不起来九成不是逻辑错而是环境和资源。下面几条都是打开这类工程时反复遇到的按现象、原因、解决写照着对一遍基本能排掉大部分问题。5.1 编译期报 CString 与 const char* 无法转换现象一编译就是一片红报错集中在CString和char*、std::string互转的地方或者strcpy、sprintf提示不安全。原因工程原本是“使用多字节字符集”换到新环境后工程属性变成了 UnicodeCString的底层类型从char变成wchar_t所有隐式转换全部失效。这不是代码写错是配置变了。解决先确认是要保持多字节还是迁移到 Unicode。要保持原样在工程属性里把字符集改回“使用多字节字符集”一步到位。要迁移就在转换处显式写CStringA/CStringW或者用CA2W、CW2A宏包一层转换别用(LPCTSTR)强转那是把问题推迟到运行期。两种方案选一个执行到底不要一半改一半没改那样报错会更多。5.2 程序能启动但棋盘是灰的、棋子不显示现象窗口出来了菜单也在客户区一片灰或者只有棋盘线棋子位图一个都没贴上去。原因资源加载失败。常见三种情况.rc里的资源 ID 和resource.h里的宏对不上res目录没有跟着工程走换机器后路径失效位图颜色数或格式不被LoadBitmap支持。解决在加载位图的地方下断点看LoadBitmap返回的句柄是不是NULL。如果是先用资源视图确认 ID 存在再检查res目录是否完整。确认资源在但句柄仍为空就换LoadImage并显式指定LR_LOADFROMFILE和路径绕开资源编译环节先把图显示出来再回头查资源脚本。棋盘线正常但棋子不显示问题一定在贴图那一段不在坐标换算。5.3 运行越久越卡最后界面不再刷新现象刚启动很流畅连续下几十步、反复悔棋之后界面开始卡顿任务管理器里 GDI 对象数一直涨。原因GDI 对象泄漏。典型是CreateCompatibleDC配了CreateCompatibleBitmap只DeleteDC没把位图SelectObject换回去或者在OnPaint里CreatePen、CreateFont之后没DeleteObject。局部CGdiObject派生类析构时会尝试删除但对象还挂在 DC 里时删除会失败句柄就漏了。解决所有SelectObject都成对写换入之后立刻用一个变量记住旧对象函数结束前换回。画笔、字体这类对象要么用CPen/CFont局部对象自动管理要么显式DeleteObject。排查时在任务管理器里加一列“GDI 对象”正常应该稳定在一个数值附近只涨不降就是漏了再按绘制路径逐个函数排查。5.4 Debug 能跑Release 一启动就崩现象调试配置下一切正常切到发布配置程序启动即退出或者第一步棋就崩。原因未初始化变量在 Debug 下被编译器填了 0Release 下是随机值或者某个断言里的副作用代码被NDEBUG裁掉了比如把MakeMove写在了ASSERT里面。解决先把所有成员变量在构造函数里显式初始化尤其是棋盘数组和搜索用的计数器。再全局搜一遍ASSERT确认括号里没有带副作用的表达式。如果崩溃点在搜索里优先查数组越界Release 不做边界检查m_board[nr][nc]里nr越界在 Debug 下可能刚好落在填充区不报错Release 下直接访问违例。开启编译器的运行时检查跑一遍能快速定位到具体行。5.5 升级平台工具集后一堆链接错误现象用新版本 IDE 打开提示升级工程升级完编译报找不到mfc*.lib、nafxcw.lib之类的链接错误。原因MFC 的链接方式静态库/动态库和平台工具集不匹配或者升级过程把“使用 MFC”改成了“不使用”。解决工程属性里确认“使用 MFC”处于“在共享 DLL 中使用 MFC”或“在静态库中使用 MFC”二者选一个与运行库设置保持一致静态 MFC 配/MT共享 MFC 配/MD混用必报链接错误。升级前先备份一份原始工程文件升级失败还能退回去。这类问题的特点是错误信息长、指向的库找不到实际原因就一个配置项别被报错行数吓到。6. 二次开发把悔棋栈和棋谱记录做扎实界面和 AI 都能跑之后真正决定这份源码好不好用的是悔棋和棋谱这两块。它们看着是附属功能实际是检验规则层封装质量的试金石如果MakeMove/UnmakeMove写得干净悔棋就是弹一次栈如果规则层到处直接改棋盘悔棋就得靠保存整个棋盘快照内存和一致性都难看。6.1 悔棋栈存什么才够回退最小可回退信息是走法本身加被吃子再加一个“走这步之前轮到谁”。棋盘不用整体快照因为UnmakeMove已经能根据走法和被吃子精确还原。struct ChessMove { int fromRow, fromCol, toRow, toCol; int captured; // 被吃子编码0 表示空走 bool redTurn; // 走这一步之前是否红方走 }; std::vectorChessMove m_history; // 悔棋栈 void CChessGame::DoMove(const ChessMove mv) { mv.captured m_board.MakeMove(mv); // 记录被吃子 m_history.push_back(mv); m_redTurn !m_redTurn; } bool CChessGame::UndoMove() { if (m_history.empty()) return false; ChessMove mv m_history.back(); m_history.pop_back(); m_board.UnmakeMove(mv, mv.captured); m_redTurn mv.redTurn; return true; }redTurn必须存不能靠“悔一步就换边”推。人机对弈时一次“悔棋”往往要退两步退掉 AI 的应手再退自己的如果按换边推退两步之后又回到原边看起来对但一旦出现 AI 思考被中断、走法没入栈的情况边次就乱了。存下走子前的边次退几步都对。6.2 棋谱记录中文记谱的列号方向中文记谱炮二平五、马8进7是这类程序最容易被忽略的细节。红方列号从右往左数一到九黑方从左往右数1到9方向相反转换时最容易在这里翻车。局面要素红方黑方列号方向从右往左一至九从左往右1 至 9进/退含义向对方底线为进向对方底线为进平的含义同一横线左右移动同一横线左右移动实现时把“数组列号”转“记谱列号”写成两个小函数红方9 - col再转中文数字黑方直接col 1。落子后把走法转成字符串追加到棋谱列表同时保留原始的ChessMove结构复盘时按结构回放不要解析字符串回放否则一旦记谱格式微调历史棋谱全废。验证这整套东西我习惯用一组固定用例开局炮二平五、马8进7、马二进三、车9平8走完比对棋盘上四个棋子的位置然后连续悔四步比对是否精确回到初始局面再重放一遍棋谱比对两次的棋盘哈希是否一致。这三步跑通规则层和悔棋层基本就稳了。从那以后我每次改完走法生成函数都会先跑这套用例再打开界面省下大量“看着不对但说不清哪不对”的时间。希望帮到你。本文还有配套的精品资源点击获取