中国象棋 Visual C++ 源码:MFC 界面与 Alpha-Beta 搜索

发布时间:2026/10/28 8:41:58
中国象棋 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里按客户区宽高重新算:水平方向留出左右边距后除以 8(9 个交点之间是 8 段),垂直方向除以 9(10 个交点之间是 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::vector<Move>& 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::vector<Move>& 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标志把炮的两种行为拆得很清楚,比“数中间有几个子”的写法少一层循环。

马的走法要单独判马腿:目标点是(r+2, c+1)时,马腿在(r+1, c);目标点是(r+1, c+2)时,马腿在(r, c+1)。八个方向对应八个马腿位置,写成两张静态表最省事。象眼同理,走(r+2, c+2)时象眼在(r+1, c+1),另外加一条“象不过河”的判断:红象的row必须大于等于 5,黑象必须小于等于 4。士和将的九宫限制是列 3 到 5,红方行 7 到 9,黑方行 0 到 2。这些边界如果写反,表现是棋子能走出九宫,肉眼一看就知道,反而好查。

3.3 合法性过滤:试走、自将检测、将帅照面

伪合法走法生成完之后,统一走一层过滤:把每个走法在棋盘上试走一步,检查己方将帅是否处于被攻击状态,是就丢弃。这个做法慢,但正确性最容易保证,深度 4 层以内的搜索完全撑得住。追求性能的写法是维护“被攻击格”增量信息,不过那是引擎优化阶段的事,第一版不要碰。

// 统一过滤:试走一步,若己方将帅被攻击或双方照面,则该走法非法 std::vector<Move> CChessBoard::GenerateLegalMoves(int side) { std::vector<Move> 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::vector<Move> 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提示不安全。

原因:工程原本是“使用多字节字符集”,换到新环境后工程属性变成了 Unicode,CString的底层类型从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 下被编译器填了 0,Release 下是随机值;或者某个断言里的副作用代码被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::vector<ChessMove> 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,走完比对棋盘上四个棋子的位置;然后连续悔四步,比对是否精确回到初始局面;再重放一遍棋谱,比对两次的棋盘哈希是否一致。这三步跑通,规则层和悔棋层基本就稳了。从那以后我每次改完走法生成函数,都会先跑这套用例再打开界面,省下大量“看着不对但说不清哪不对”的时间。希望帮到你。

本文还有配套的精品资源,点击获取