简介:《中国象棋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::vector<Move>& 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::vector<Move>& 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::vector<Move>& 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,同时还要检查象眼(r+tr)/2, (c+tc)/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..15,0 和 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::vector<Move> 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_map<uint64_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++ 程序这条路走顺。
本文还有配套的精品资源,点击获取