简介《中国象棋Visual C程序》是一套面向C与MFC初学者、课程设计及毕业设计参考者的完整桌面象棋项目围绕人机、人人、机机三种对弈模式帮助读者理解图形界面开发、棋规建模与搜索算法落地。压缩包共112个文件、约1.84MB以27个h头文件和25个cpp源文件为主体配有obj编译中间文件、ico与bmp界面图像资源、exe可执行程序及rc资源脚本等既方便直接运行体验也便于对照源码梳理工程结构。已有221人学习说明其在同类象棋项目中具备一定参考价值。读者可从中掌握对话框与控件的事件驱动编程、棋盘绘制与落子交互、走法合法性检查以及Minimax/Alpha-Beta剪枝和评估函数的基本实现并了解异常处理与调试思路适合用作C课程实践和MFC入门后的综合练习。1. 从一份能编译的中国象棋 Visual C 程序说起它到底解决了什么问题想找一份能完整跑起来的中国象棋 Visual C 程序练手其实比想象中难网上不少半成品能编译的没界面有界面的走子规则又错。这份源码属于比较完整的那种——打开 .sln 编译出来就是一个固定大小的窗口木质底色棋盘、红黑棋子全用 GDI 画出来鼠标点一下选中、再点一下落子对面用极大极小加 Alpha-Beta 搜索回你一步还带悔棋、重新开局和存档。它适合三类人刚学完 C 想找个带界面的项目看工程结构想搞懂棋类 AI 搜索到底怎么落地手上有个老 MFC 工程要接着改。规则、搜索、绘制三条线在源码里摊开没有黑盒。2. 工程骨架与界面层从 .sln 到视图类的一份目录地图拿到一份 Visual C 源码最容易犯的错是直接双击 .sln 然后 F5编译报错一片红就扔到一边。稳妥的做法是先花十分钟把目录和类关系看清楚知道哪个文件管状态、哪个管绘制、哪个管 AI后面改起来才不会像拆炸弹。2.1 目录与文件职责这类程序一般不会是单文件几百行的玩具比较规范的写法会按职责拆文件。下面是常见的组织方式你的压缩包里名字可能略有出入但大类基本对得上Chess/ ├── Chess.sln # 解决方案入口 └── Chess/ ├── Chess.vcxproj # 工程文件工具集、字符集都在这 ├── ChessApp.h/.cpp # CWinApp 派生类程序入口 InitInstance ├── MainFrm.h/.cpp # 主框架窗口菜单、工具栏挂在这 ├── ChessDoc.h/.cpp # 文档类持有棋局和着法历史 ├── ChessView.h/.cpp # 视图类OnDraw 绘制 鼠标消息处理 ├── Board.h/.cpp # 棋盘数组、棋子编码、走子/撤销 ├── MoveGen.h/.cpp # 七个兵种的走法生成 ├── Eval.h/.cpp # 局面对红黑双方的评分 ├── Search.h/.cpp # 极大极小、Alpha-Beta、置换表 ├── res/ # 图标、位图、.rc 资源脚本 └── stdafx.h / pch.h # 预编译头新旧工程命名不同三个文件值得先盯住ChessDoc是棋局的唯一数据源棋盘数组、轮到谁走、历史着法都放在这里ChessView只负责把 Doc 里的状态画出来并把鼠标点击翻译成一次落子请求Board和MoveGen是纯算法层理论上不依赖 MFC可以单独拿出来跑控制台测试。这种分层看着朴素但对你二次开发极友好——想换皮肤只动 View想加棋力只动 Eval 和 Search。顺便说一句stdafx.h和pch.h的差别不是小事。前者是 VS2015 以前的预编译头命名后者是 VS2017 之后 VS 新建 MFC 工程默认的名字。如果工程里还是#include stdafx.h而你的 VS 版本较新编译时会报找不到头文件这时候要么把预编译头设置关掉要么统一改名别两个名字混着留。2.2 文档/视图结构与消息映射MFC 单文档程序的生命周期是固定的CChessApp::InitInstance里创建CSingleDocTemplate把文档类、框架类、视图类绑在一起然后OnNewDocument建新棋局OnDraw第一次绘制。这层模板代码不用背但要知道棋局的初始化发生在OnNewDocument而不是OnInitialUpdate很多人把初始局面写错地方导致点重新开局没反应。消息映射把界面事件接进代码这是 MFC 里最像配置的部分BEGIN_MESSAGE_MAP(CChessView, CView) ON_WM_LBUTTONDOWN() // 鼠标左键选中 / 落子 ON_WM_RBUTTONDOWN() // 右键取消选中 ON_WM_ERASEBKGND() // 拦截背景擦除防闪烁 ON_COMMAND(ID_GAME_NEW, CChessView::OnGameNew) ON_COMMAND(ID_GAME_UNDO, CChessView::OnGameUndo) ON_COMMAND(ID_GAME_SAVE, CChessView::OnGameSave) END_MESSAGE_MAP()ON_WM_ERASEBKGND这一行容易被忽略但它决定了窗口拖动时闪不闪。默认实现会用背景刷把整个客户区刷白再等OnDraw重画中间那一帧就是白的。把消息映射过来并在处理函数里直接return TRUE告诉框架背景我自己管配合双缓冲就干净了。2.3 双缓冲绘制与鼠标落子OnDraw是整个界面层最核心的函数所有棋盘线、九宫斜线、棋子圆、选中高亮都在这里画。直接往屏幕 DC 上画量大了必然闪所以标准做法是先画到内存位图再一次贴上去void CChessView::OnDraw(CDC* pDC) { CRect rc; GetClientRect(rc); // 1. 建一个和客户区等大的兼容位图作为画布 CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, rc.Width(), rc.Height()); CBitmap* pOldBmp memDC.SelectObject(bmp); // 2. 全部绘制都落在 memDC 上屏幕上看不到中间过程 DrawBackground(memDC, rc); DrawGrid(memDC); // 9 条竖线 10 条横线 两处九宫斜线 DrawRiver(memDC); // 楚河汉界四个字 DrawPieces(memDC); // 遍历 board[10][9]非空就画圆 写字 // 3. 一次性贴到屏幕 pDC-BitBlt(0, 0, rc.Width(), rc.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }参数上只有一处需要你按实际改MARGIN棋盘左上角留白和CELL格宽。9 列 10 行的棋盘横向长度是8 * CELL纵向是9 * CELL所以窗口尺寸至少要满足2*MARGIN 8*CELL和2*MARGIN 9*CELL。我在工程里习惯把这两个常量放在ChessView.h顶部改一处全局生效比散落在各个函数里写死数字强得多。鼠标点击进来后第一件事是把像素坐标换成棋盘行列这一步换算错了表现出来就是点了没反应或者点这个子选中了旁边那个// 像素坐标 - 棋盘行列返回 false 表示没点在任何交叉点附近 bool CChessView::HitTest(CPoint pt, int row, int col) { int c (pt.x - MARGIN CELL / 2) / CELL; // 四舍五入到最近的列 int r (pt.y - MARGIN CELL / 2) / CELL; if (r 0 || r 9 || c 0 || c 8) return false; // 还要判断点击位置离交叉点够近避免点到格子边缘误判 int cx MARGIN c * CELL; int cy MARGIN r * CELL; if (abs(pt.x - cx) CELL / 2 - 2 || abs(pt.y - cy) CELL / 2 - 2) return false; row r; col c; return true; } CELL / 2是关键整数除法是截断不是四舍五入少了它就等于把整个棋盘的判定区往右下偏了半格。后面那层离交叉点够近的过滤也别省不然点在两个子中间会随机选一个体验很差。2.4 编译运行三步把工程拉起来拿到源码先别急着按 F5按顺序做三件事成功率最高。第一用 Visual Studio 打开.sln如果提示平台工具集未安装右键工程 → 属性 → 常规 → 平台工具集改成你本机装了的那个版本。第二确认属性 → 高级 → MFC 的使用是在共享 DLL 中使用 MFC这一项如果空着链接阶段会报一堆_afx相关的未解析外部符号。第三属性 → 高级 → 字符集看清是 Unicode 还是多字节。第三步最容易翻车。这份源码里的字符串如果是老写法楚河汉界直接传给TextOut工程又设成 Unicode编译能过但显示会乱码或者干脆空白。反过来如果代码里到处是_T(...)包裹而字符集设成了多字节也不会报错只是有些 API 的表现会变。以工程设置里的字符集为准别凭感觉。3. 棋盘表示与走法生成90 个交叉点上的规则引擎界面再好看规则错了整个程序就是废的。中国象棋的走法规则看着简单真正写起来坑不少马腿、象眼、炮翻山、兵过河后横走、将帅不能照面、士象不能出九宫任何一条漏了AI 就会走出让你怀疑人生的棋。3.1 棋盘数组与棋子编码最直观的表示是一个int board[10][9]行 0 在最上方黑方底线行 9 在最下方红方底线列 0 到 8 从左到右。红方在下面往上走黑方在上面往下走河界在行 4 和行 5 之间。这种红在下的布局跟人看棋盘的直觉一致画图和鼠标换算都不用再翻转。棋子编码用整数区间区分阵营比字符串比较快得多也方便写评估表enum PieceType { EMPTY 0, R_KING 1, R_ADVISOR, R_BISHOP, R_KNIGHT, R_ROOK, R_CANNON, R_PAWN, // 红 1..7 B_KING 9, B_ADVISOR, B_BISHOP, B_KNIGHT, B_ROOK, B_CANNON, B_PAWN // 黑 9..15 }; inline bool IsRed(int p) { return p 1 p 7; } inline bool IsBlack(int p) { return p 9 p 15; } inline int OwnerOf(int p) { return p EMPTY ? -1 : (p 9 ? 0 : 1); } // 0 红 1 黑留出 8 这个空档不是浪费是为了让p 9能一句话判断阵营同时也给被吃掉的子留个占位值。OwnerOf返回 -1 表示空格这种写法在走法生成里调用频率极高写成inline让编译器展开比定义一堆宏可读性好。初始局面用一个静态数组一次性铺进去别在代码里一行行board[9][4] R_KING;地写那样想加个新残局测试都没法复用void Board::Reset() { static const int INIT[10][9] { { 12, 11, 10, 9, 14, 13, 15, 11, 12 }, // 黑方底线车马象士将士象马车 { 0, 0, 0, 0, 0, 0, 0, 0, 0 }, { 0, 16, 0, 0, 0, 0, 0, 16, 0 }, // 黑炮 { 17, 0, 17, 0, 17, 0, 17, 0, 17 }, // 黑卒 { 0, 0, 0, 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0 }, { 7, 0, 7, 0, 7, 0, 7, 0, 7 }, // 红兵 { 0, 6, 0, 0, 0, 0, 0, 6, 0 }, // 红炮 { 0, 0, 0, 0, 0, 0, 0, 0, 0 }, { 4, 3, 2, 1, 8, 5, 6, 3, 4 } // 红方底线 }; memcpy(m_grid, INIT, sizeof(INIT)); m_side 0; // 红先 m_history.clear(); }注意这里 8 用在了红帅的位置上跟前面的OwnerOf判断有冲突——这种细节必须统一。我一般把红帅定成 1、黑将定成 9把 8 和 16 留给红炮/黑炮这种需要单独编号的场景不占将帅的号。写的时候一旦发现自己要改编码立刻全局搜索所有硬编码数字不然调试到半夜都不知道哪漏了一个。3.2 走法生成七个兵种各自的边界条件走法生成的统一签名一般是这样传入棋盘和走子方输出所有伪合法着法struct Move { int fromR, fromC, toR, toC; }; void MoveGen::Generate(const int grid[10][9], int side, std::vectorMove out) { out.clear(); for (int r 0; r 10; r) for (int c 0; c 9; c) { int p grid[r][c]; if (p EMPTY || OwnerOf(p) ! side) continue; switch (p % 8) { // 1..7 对应七种兵种 case R_KING: GenKing (grid, r, c, side, out); break; case R_ADVISOR: GenAdvisor(grid, r, c, side, out); break; case R_BISHOP: GenBishop(grid, r, c, side, out); break; case R_KNIGHT: GenKnight(grid, r, c, side, out); break; case R_ROOK: GenRook (grid, r, c, side, out); break; case R_CANNON: GenCannon(grid, r, c, side, out); break; case R_PAWN: GenPawn (grid, r, c, side, out); break; } } }p % 8这个取模把红黑两套编码压成一套处理逻辑省掉了一倍的分支。前提是编码设计时红黑对应兵种的偏移量刚好是 8这就是前面留空档的价值。马的走法最容易写错八个方向对应八个马腿位两个数组必须严格对齐static const int KD[8][2] {{-2,-1},{-2,1},{-1,-2},{-1,2},{1,-2},{1,2},{2,-1},{2,1}}; static const int KL[8][2] {{-1, 0},{-1,0},{ 0,-1},{ 0,-1},{0, 1},{0, 1},{ 1, 0},{ 1, 0}}; void MoveGen::GenKnight(const int g[10][9], int r, int c, int side, std::vectorMove out) { for (int i 0; i 8; i) { int lr r KL[i][0], lc c KL[i][1]; // 马腿位置 if (lr 0 || lr 9 || lc 0 || lc 8) continue; if (g[lr][lc] ! EMPTY) continue; // 蹩马腿直接跳过 int tr r KD[i][0], tc c KD[i][1]; // 落点 if (tr 0 || tr 9 || tc 0 || tc 8) continue; if (OwnerOf(g[tr][tc]) side) continue; // 不能吃自己的子 out.push_back({r, c, tr, tc}); } }炮的生成跟车共用一个沿直线走的循环区别只在遇到第一个子之后怎么处理void MoveGen::GenCannon(const int g[10][9], int r, int c, int side, std::vectorMove out) { static const int DIR[4][2] {{-1,0},{1,0},{0,-1},{0,1}}; for (auto d : DIR) { int tr r d[0], tc c d[1]; bool jumped false; // 是否已经翻过炮架 while (tr 0 tr 9 tc 0 tc 8) { int p g[tr][tc]; if (!jumped) { if (p EMPTY) out.push_back({r, c, tr, tc}); // 空格可平移 else jumped true; // 遇到炮架切换模式 } else { if (p ! EMPTY) { // 炮架后的第一个子 if (OwnerOf(p) ! side) out.push_back({r, c, tr, tc}); break; // 无论吃不吃都停 } } tr d[0]; tc d[1]; } } }jumped这个布尔值是整个炮逻辑的核心少写break就会让炮一路吃过去表现出来就是炮能连吃好几个子这种 bug 肉眼很难发现往往要等 AI 走出一步神仙棋才暴露。士、象、将的生成都要加九宫或本方半场的范围判断。红士限制在r ∈ [7,9] c ∈ [3,5]黑士在r ∈ [0,2]象走田字且不能过河红象r 5黑象r 4同时还要检查象眼(rtr)/2, (ctc)/2是否为空将帅只能在自己的九宫内走一步直线。这些边界用常量数组写清楚比塞一堆if好维护。3.3 将军判定与合法性过滤走法生成出来的是伪合法着法——它保证不违反走子规则但不保证走完之后自己的将帅不被吃。真正的合法性要在试走之后检查bool Board::IsLegal(const Move m) { int cap m_grid[m.toR][m.toC]; m_grid[m.toR][m.toC] m_grid[m.fromR][m.fromC]; // 试走 m_grid[m.fromR][m.fromC] EMPTY; bool ok !InCheck(m_side) !KingsFacing(); m_grid[m.fromR][m.fromC] m_grid[m.toR][m.toC]; // 撤销 m_grid[m.toR][m.toC] cap; return ok; }InCheck的做法是找到己方将帅位置然后反向生成对方所有着法看能不能落到这个点上KingsFacing处理将帅照面沿两将所在的列从上往下扫中间没有子就判非法。这两个函数每次试走都调用一次搜索深度上来以后会成为热点所以实现里不要用std::vector频繁分配用一个固定大小的栈数组接结果。还有一条规则容易被漏掉长将判负。严格的裁判逻辑要记录重复局面简化版通常只做同一局面出现三次就判和或者干脆不处理。如果是自己做着玩不处理问题不大但如果要拿去跟别的引擎对弈这条不补会被对面用循环将军磨死。4. 评估与搜索让程序真正会下棋的那部分界面和规则解决的是能不能玩搜索和评估解决的是好不好玩。很多象棋程序 Demo 卡在这一步能走子但 AI 只会一步吃子深度一加就卡死。问题多半不在搜索算法本身而在评估函数写得太糙或者剪枝没写对导致节点爆炸。4.1 评估函数子力价值加位置分评估函数返回的是当前走子方视角下的局面分正数表示对我有利。最基础的版本只有子力价值// 索引对应编码 0..150 和 8、16 之外都填上 static const int VAL[16] { 0, // 空 10000, 200, 200, 400, 900, 450, 100, // 红帅 士 象 马 车 炮 兵 0, 10000, 200, 200, 400, 900, 450, 100 // 黑 };车 900、马 400、炮 450、兵 100这套数值是棋类引擎里的常用比例你不用纠结具体数字只要保证车明显大于马炮、马炮大于士象、士象大于兵就行。真正拉开差距的是位置分——同一个兵过河前和过河后价值完全不同// 红兵位置加成越靠前、越靠近中路越值钱 static const int PAWN_POS[10][9] { { 0, 0, 0, 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0 }, { 0, 0, 0, 0, 0, 0, 0, 0, 0 }, // 河界 { 10, 12, 20, 30, 40, 30, 20, 12, 10 }, // 刚过河 { 20, 25, 40, 60, 80, 60, 40, 25, 20 }, { 30, 40, 60, 90,120, 90, 60, 40, 30 }, { 40, 50, 80,120,160,120, 80, 50, 40 }, // 逼近九宫 { 0, 0, 0, 0, 0, 0, 0, 0, 0 } }; int Eval::Evaluate(const int g[10][9], int side) { int score 0; for (int r 0; r 10; r) for (int c 0; c 9; c) { int p g[r][c]; if (p EMPTY) continue; int v VAL[p]; if (p % 8 R_PAWN) v IsRed(p) ? PAWN_POS[r][c] : PAWN_POS[9 - r][c]; // 黑方镜像 score IsRed(p) ? v : -v; } return side 0 ? score : -score; // 统一成走子方视角 }9 - r的镜像不要省红黑兵的方向是相反的忘了镜像就等于给对手的兵也加了分AI 会莫名其妙地往前送兵。马和炮也可以加位置表思路一样马在中间比在边线值钱炮在中路比在角落值钱。表不用手搓找个开源布局摆几盘把感觉量化成数字就行。评估函数写完后一定要做个单调性测试摆一个红方多一个车的局面看返回值是不是明显为正摆一个红方被将军的局面返回值应该是负的且绝对值较大。这一步十分钟能省掉后面几个小时AI 为什么不走好棋的排查。4.2 极大极小与 Alpha-Beta 剪枝棋类搜索的标准写法是负极大值Negamax红黑双方共用一个函数靠取负来切换视角代码量只有普通极大极小的三分之一int Search::AlphaBeta(int g[10][9], int depth, int alpha, int beta, int side) { m_nodes; if (depth 0) return Eval::Evaluate(g, side); std::vectorMove moves; MoveGen::Generate(g, side, moves); if (moves.empty()) return -MATE (m_maxDepth - depth); // 无子可动判负 OrderMoves(g, moves); // 先走吃子和大子提高剪枝率 int best -INF; for (const Move m : moves) { int cap g[m.toR][m.toC]; g[m.toR][m.toC] g[m.fromR][m.fromC]; g[m.fromR][m.fromC] EMPTY; if (!InCheck(g, side) !KingsFacing(g)) { // 合法才继续 int v -AlphaBeta(g, depth - 1, -beta, -alpha, 1 - side); if (v best) best v; if (v alpha) alpha v; } g[m.fromR][m.fromC] g[m.toR][m.toC]; // 撤销 g[m.toR][m.toC] cap; if (alpha beta) break; // 剪枝后面的分支不用看了 } return best; }四个参数里alpha是我方已知能拿到的最好分beta是对手已知能拿到的最好分alpha beta说明这条线对手不会让我走到直接砍掉。m_maxDepth - depth那个偏移量是给越快将死分越高用的否则 AI 会在能五步将死的时候磨蹭到十步。剪枝效果完全取决于走法顺序。上面OrderMoves的常见做法是 MVV-LVA按被吃子价值减攻击子价值排序先试吃大子的着法。顺序对了同样深度能省掉一半以上的节点顺序乱了Alpha-Beta 退化成裸的极大极小深度加到 5 就明显卡。4.3 置换表与时间控制搜索到深度 4 以上重复局面会大量出现同一个局面从不同路径走到评估结果完全一样。置换表就是拿 Zobrist 哈希做键把这个局面在某个深度下搜出来的值缓存起来struct TTEntry { uint64_t key; // Zobrist 哈希 int depth; // 搜索深度 int value; // 评估值 int flag; // EXACT / LOWER / UPPER };Zobrist 的做法是给每个棋子的每个位置预生成一个随机数走子时用异或更新撤销时再异或回来一次更新只要两三条指令。表大小一般开 2 的幂用key (SIZE-1)取索引冲突了直接覆盖——不用处理链表棋类引擎里这种覆盖式实现就够了。时间控制是另一回事。固定深度搜索的问题在于中局分支多的时候一步能想十几秒用户会以为程序死了。常见做法是设一个 1 到 3 秒的预算搜索过程中每遍历若干个节点就查一次GetTickCount64超时就抛一个标志位逐层退出返回上一层已经搜到的最好着法。这个半途而废的着法质量不如完整搜索但体验上远好于卡住不动。5. 常见问题排查编译、绘制、搜索里的翻车现场这一节列的都是我在拆这类工程时真遇到过的坑按现象 → 原因 → 解决写你照着对号入座基本能省掉大半天。5.1 一编译就报一堆_afx未解析的外部符号现象代码本身没报红链接阶段几百行LNK2019全是__imp__AfxWinInit、_afxThreadState之类。原因MFC 的使用方式没配对。工程属性里使用 MFC设成了不使用但代码里#include afxwin.h是实打实的或者反过来代码里没引 MFC 头文件却开了共享 DLL。还有一种情况是运行库设置不一致有的 .cpp 用/MT有的用/MD混着链就报这个。解决属性 → 高级 → MFC 的使用改在共享 DLL 中使用 MFC属性 → C/C → 代码生成 → 运行库Debug 统一用多线程调试 DLL (/MDd)Release 统一用多线程 DLL (/MD)。改完先清理解决方案再重新生成别只点生成增量编译有时候不会重链全部目标文件。5.2 棋盘画出来了但棋子全挤在左上角现象窗口能出来棋盘线也对九个棋子叠在第一个交叉点上或者整体偏移半格。原因八成是坐标换算和绘制用的不是同一套公式。绘制时用的是MARGIN c * CELL鼠标换算里写成了MARGIN (c - 1) * CELL或者行列写反了——board[r][c]里 r 是行画的时候x用了 r、y用了 c。解决把坐标换算抽成一个函数CellToPoint(r, c)绘制和鼠标点击都调它别在两处各写一遍。写完之后打印几组值验证CellToPoint(0,0)应该是(MARGIN, MARGIN)CellToPoint(9,8)应该是(MARGIN 8*CELL, MARGIN 9*CELL)。这一步花两分钟比盯着屏幕猜半小时强。5.3 拖动窗口时闪成一片白现象窗口最大化、拖动、被别的窗口遮挡再露出来时整块客户区先闪白再重画。原因默认的OnEraseBkgnd用背景刷把客户区擦了一遍然后OnDraw才画两帧之间的空档就是白的。双缓冲只解决了OnDraw内部的闪烁管不了背景擦除。解决消息映射里加上ON_WM_ERASEBKGND处理函数直接return TRUE;表示背景我已经处理了。同时确认OnDraw里第一件事是把整个客户区涂满FillSolidRect否则残留的旧内容会透出来。如果还闪检查一下是不是在OnPaint之外还调了Invalidate频繁重绘本身就是性能问题。5.4 AI 一步要想十几秒或者走完一步棋自己卡死现象点电脑走棋后界面无响应进度条不动等半天才出一手或者干脆死循环任务管理器里 CPU 占满。原因搜索没有时间控制深度写死成 6 甚至 8。中局分支因子在 40 左右就算有 Alpha-Beta节点数也是百万级往上。另一种可能是走法生成里出现了非法着法比如将帅照面没过滤搜索树里出现自己吃自己将的循环深度永远退不出来。解决先加节点计数搜索前后打印m_nodes看是不是爆炸式增长。如果节点数正常但耗时长就是评估函数太重把std::vector换成预分配的数组。如果有循环迹象在IsLegal里补上KingsFacing检查。时间控制按 4.3 的思路加节点数每增 4096 查一次时钟超时用异常或者返回值往外抛。5.5 悔棋之后走子错乱或者存档读回来棋子位置不对现象点两次悔棋界面上的棋子回去了但接着走一步AI 回的位置完全对不上或者存档再读档红黑方轮到谁走变了。原因棋局状态散落在多个地方——棋盘数组在Board里、历史着法在Doc里、当前走子方是个成员变量、界面的选中格子又是另一份。悔棋只回退了数组忘了回退m_side存档只写了棋盘没写轮到谁。这就是典型的状态多处副本问题。解决把棋局状态收敛成一个结构体棋盘、走子方、历史着法、重复局面计数全放进去悔棋就是整体回退一步存档就是整体序列化。界面里的选中格子属于纯 UI 状态不进存档、不进悔棋栈。这个重构做完后面加复盘、加分支变化都轻松。6. 二次开发三条能立刻上手的改造路线把这份源码跑通之后多数人下一步是想让它更强或者更好看。我给三条我自己走过的路线按投入产出比排序。第一条是提棋力成本最低。评估函数里的位置表是拍脑袋定的你可以用自对弈的方式调参让新旧两个版本各走 100 盘统计胜率胜率高的那版参数留下。这件事不需要算法功底需要的是耐心和一台能跑一晚上的机器。开局库也能加把常见开局的前十几步写成一个文本文件哈希匹配上了直接查表省掉开局阶段的搜索时间。// 极简开局库key 是 Zobrist 哈希value 是预设着法 std::unordered_mapuint64_t, Move g_openingBook { { 0x9A3C..., {9, 1, 7, 2} }, // 炮二平五 { 0x4F17..., {9, 7, 7, 6} }, // 马二进三 };第二条是换皮。把所有绘制逻辑从OnDraw里抽出来定义一组虚函数DrawBoard / DrawPiece / DrawHighlight然后写两个实现类一个继续用 GDI 画矢量一个改成贴位图。这样切换皮肤只要改一个工厂函数的返回值。抽的时候注意别把 MFC 的CDC*漏进接口里用你自己的Canvas抽象包一层将来想换 Direct2D 或者换成 Qt 都不用重写业务代码。第三条是把引擎抽成独立模块对外提供接口。现在搜索和棋盘是绑在 MFC 工程里的抽出来做成一个静态库只暴露Init / SetPosition / Go / GetBestMove四个函数MFC 界面变成它的一个调用方。这么改完你就可以写个控制台程序批量跑自对弈也可以接一个标准棋类协议让别的界面来调它。抽的时候先跑回归固定 20 个残局改造前后各跑一遍最佳着法必须一致不一致就是状态没隔离干净。最后说个我自己的习惯。这类带搜索的程序最怕的不是写得慢是错了但看起来没错——AI 走出一步臭棋你没法一眼看出是评估函数的锅还是剪枝写错了。所以我现在每次动搜索相关代码之前都会先在一个只有几个子的残局上跑一遍把每个节点的 depth、alpha、beta、返回值打到日志里人工核对前两层。这个习惯是从一次通宵排查AI 把自己的车送掉开始的后来发现是撤销着法时漏恢复了被吃子一个cap变量的事硬是找了六个小时。希望你不用走这条弯路也希望这份源码能帮你把中国象棋 Visual C 程序这条路走顺。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?