资讯动态

中国象棋 Visual C++ 程序:从棋盘建模到可运行的最小闭环

发布时间:2026/10/25 15:52:32 来源:尧图企业网站定制
简介这是一份面向C初学者与Windows桌面开发爱好者的中国象棋完整工程源码基于Visual C与MFC构建可运行人机对弈、人人对弈与机机对弈三种模式帮助读者理解棋类规则建模、GUI交互与搜索算法的落地方式。压缩包共112个文件约1.84MB以h头文件与cpp源文件为核心配合obj编译中间文件、ico与bmp图像资源、rc资源脚本及dsw、dsp工程文件另有exe可执行程序与cm棋谱文件结构完整可直接编译调试。目前已有220人学习下载。项目涵盖棋盘合法性检查、Alpha-Beta剪枝与Minimax评估函数、窗口控件事件响应及异常处理等知识点适合作为课程设计、毕业设计或自学MFC与AI搜索的实践参考读者可据此掌握从规则编码到界面交互的完整开发链路。1. 中国象棋 Visual C 程序从棋盘建模到可运行的最小闭环很多人第一次在 Visual C 里写中国象棋卡住的地方不是规则而是「棋盘怎么存、棋子怎么走、界面怎么画」这三件事互相纠缠。我见过太多半成品棋盘用二维数组存了但走法判断写成一堆 if-else改一个规则要动十几个地方界面用 MFC 画了结果重绘时棋子闪烁调试半天发现是 Invalidate 调用时机不对。这个标题背后真正要解决的问题是把中国象棋的规则引擎和 Windows 界面拆成两层让规则可测、界面可换。这篇文章面向两类人一是学过 C 语法、想找一个完整项目练手的在校生二是需要把棋类逻辑嵌入现有 Windows 工具的工程师。我会按「数据结构 → 走法生成 → 界面渲染 → 性能与调试」的顺序推进每一步都给可编译的代码片段和参数说明。Visual C 在这里不是指某个特定版本而是指用 MSVC 工具链 Win32/MFC 的开发方式你用的是 Visual Studio 2019 还是 2022 都能跟上。2. 棋盘与棋子的数据结构为什么用一维数组而不是二维2.1 棋盘表示的选择理由中国象棋棋盘是 9 列 × 10 行共 90 个交叉点。最直觉的做法是int board[10][9]但实际写走法生成时二维数组的边界判断会写到你手软。我一般用一维数组int board[90]行列转换用宏或者内联函数完成。这样做的好处是走法生成时只需要判断目标格是否在 0~89 范围内再单独处理「不能跨列」的约束代码量少一半。棋子编码用整数正数代表红方负数代表黑方绝对值代表兵种。这样判断敌我只需要看符号是否相同比用结构体存颜色字段再比较要快也省内存。具体编码如下表编码兵种编码兵种1帅/将-1将/帅2仕/士-2士/仕3相/象-3象/相4马-4马5车-5车6炮-6炮7兵/卒-7卒/兵棋盘初始化时红方在下行 7~9黑方在上行 0~2。这个方向约定很重要后面界面绘制时 y 轴方向要和它一致否则棋子会画反。2.2 初始化代码与坐标转换// board.h #pragma once #include array constexpr int COLS 9; constexpr int ROWS 10; constexpr int BOARD_SIZE COLS * ROWS; // 一维索引转行列 inline int rowOf(int idx) { return idx / COLS; } inline int colOf(int idx) { return idx % COLS; } inline int indexOf(int row, int col) { return row * COLS col; } // 棋子编码 enum PieceType { EMPTY 0, KING 1, ADVISOR 2, ELEPHANT 3, HORSE 4, ROOK 5, CANNON 6, PAWN 7 }; class Board { public: std::arrayint, BOARD_SIZE cells{}; void reset() { cells.fill(EMPTY); // 黑方 cells[indexOf(0, 0)] -ROOK; cells[indexOf(0, 1)] -HORSE; cells[indexOf(0, 2)] -ELEPHANT; cells[indexOf(0, 3)] -ADVISOR; cells[indexOf(0, 4)] -KING; cells[indexOf(0, 5)] -ADVISOR; cells[indexOf(0, 6)] -ELEPHANT; cells[indexOf(0, 7)] -HORSE; cells[indexOf(0, 8)] -ROOK; cells[indexOf(2, 1)] -CANNON; cells[indexOf(2, 7)] -CANNON; for (int c 0; c COLS; c 2) cells[indexOf(3, c)] -PAWN; // 红方 cells[indexOf(9, 0)] ROOK; cells[indexOf(9, 1)] HORSE; cells[indexOf(9, 2)] ELEPHANT; cells[indexOf(9, 3)] ADVISOR; cells[indexOf(9, 4)] KING; cells[indexOf(9, 5)] ADVISOR; cells[indexOf(9, 6)] ELEPHANT; cells[indexOf(9, 7)] HORSE; cells[indexOf(9, 8)] ROOK; cells[indexOf(7, 1)] CANNON; cells[indexOf(7, 7)] CANNON; for (int c 0; c COLS; c 2) cells[indexOf(6, c)] PAWN; } };这段代码里reset()把棋盘恢复到开局状态。注意炮的位置在行 2 和行 7 的列 1、7兵/卒在行 3 和行 6 的偶数列。如果你把兵的位置写成奇数列开局就会变成「兵在肋道上」这是新手常犯的错。std::array比 C 数组多一层类型安全fill也比手动循环清零更不容易漏格。提示如果你用的是 Visual C 6.0 这种老环境std::array和constexpr可能不支持把std::array换成int cells[90]constexpr换成const即可逻辑不变。3. 走法生成把规则拆成「方向向量 约束函数」3.1 为什么不用 switch-case 堆规则走法生成是整个程序的核心。很多人写成一个巨大的switch(pieceType)每个分支里再写一堆if判断边界和蹩马腿。这样写能跑但调试时你没法单独测试「马」的走法因为代码和棋盘状态、敌我判断全缠在一起。我的做法是每种棋子定义一个方向向量数组再配一个约束函数。方向向量描述「从当前位置能往哪些偏移走」约束函数负责检查「这个偏移是否合法」。这样每种棋子的逻辑独立单元测试也好写。以马为例马的 8 个方向偏移是(-2,-1), (-2,1), (-1,-2), (-1,2), (1,-2), (1,2), (2,-1), (2,1)。蹩马腿的判断是如果走(-2,-1)那么(-1,0)位置必须有棋子才算蹩腿。这个「腿」的位置是偏移量的一半取整。3.2 马和炮的走法生成代码// movegen.cpp #include board.h #include vector struct Move { int from; int to; }; // 检查目标格是否可走空或敌方棋子 bool canLand(const Board b, int from, int to, bool redTurn) { int target b.cells[to]; if (target EMPTY) return true; bool targetRed target 0; return targetRed ! redTurn; // 敌我不同才能吃 } // 马的走法 void genHorseMoves(const Board b, int from, bool redTurn, std::vectorMove out) { static const int dx[8] {-2,-2,-1,-1, 1, 1, 2, 2}; static const int dy[8] {-1, 1,-2, 2,-2, 2,-1, 1}; int r rowOf(from), c colOf(from); for (int i 0; i 8; i) { int nr r dx[i], nc c dy[i]; if (nr 0 || nr ROWS || nc 0 || nc COLS) continue; // 蹩马腿腿在方向的一半处 int legR r (dx[i] 0 ? 1 : (dx[i] 0 ? -1 : 0)); int legC c (dy[i] 0 ? 1 : (dy[i] 0 ? -1 : 0)); // 如果 dx 绝对值是 2腿在行方向否则在列方向 if (dx[i] 2 || dx[i] -2) legC c; else legR r; if (b.cells[indexOf(legR, legC)] ! EMPTY) continue; int to indexOf(nr, nc); if (canLand(b, from, to, redTurn)) out.push_back({from, to}); } } // 炮的走法直线移动吃子必须隔一个棋子 void genCannonMoves(const Board b, int from, bool redTurn, std::vectorMove out) { static const int dr[4] {-1, 1, 0, 0}; static const int dc[4] {0, 0, -1, 1}; int r rowOf(from), c colOf(from); for (int d 0; d 4; d) { int nr r dr[d], nc c dc[d]; bool jumped false; while (nr 0 nr ROWS nc 0 nc COLS) { int to indexOf(nr, nc); int target b.cells[to]; if (!jumped) { if (target EMPTY) { out.push_back({from, to}); } else { jumped true; // 遇到第一个棋子准备翻山 } } else { if (target ! EMPTY) { if (canLand(b, from, to, redTurn)) out.push_back({from, to}); break; // 翻山后只能吃一个 } } nr dr[d]; nc dc[d]; } } }genHorseMoves里蹩马腿的判断逻辑是如果行方向偏移是 ±2那么腿在行方向中间、列不变如果列方向偏移是 ±2腿在列方向中间、行不变。这个写法比用 8 个 if 分支清晰得多。genCannonMoves用jumped标记是否已经遇到第一个棋子遇到之前只能走空格遇到之后只能吃第一个碰到的棋子然后停止。注意炮的走法里jumped为 true 后如果遇到空格要继续扫描不能直接 break否则炮就吃不到远处的棋子了。这个坑我在第一次写的时候踩过调试了半小时才发现。3.3 帅/将、仕/士、相/象的约束差异帅和将只能在九宫格内走红方九宫是行 7~9、列 3~5黑方是行 0~2、列 3~5。仕/士走斜线同样限制在九宫。相/象走田字不能过河红方行 5~9黑方行 0~4而且「塞象眼」的判断和蹩马腿类似只是腿在田字中心。兵/卒的走法最特殊过河前只能向前过河后可以左右。红方兵向上行号减小黑方卒向下行号增大。判断是否过河红方兵行号 4 即过河黑方卒行号 5 即过河。这些规则用方向向量 约束函数的方式写每个函数不超过 30 行。全部走法生成完后用一个genAllMoves汇总传入当前该谁走返回所有合法走法。4. 界面渲染用 GDI 双缓冲解决闪烁4.1 为什么 MFC 的 OnPaint 直接画会闪在 MFC 里如果你在OnPaint里直接CDC::Rectangle和TextOut画棋盘和棋子每次鼠标点击重绘时窗口会先擦背景再画内容人眼就能看到闪烁。这不是玄学是 Windows 绘制消息的机制决定的WM_ERASEBKGND先填充背景然后WM_PAINT才画你的内容。解决办法是双缓冲先在内存 DC 里把整幅画面画好再一次性BitBlt到屏幕 DC。这样屏幕只刷新一次闪烁消失。MFC 里可以用CMemDC或者手动创建兼容 DC。4.2 双缓冲绘制的关键代码// ChessView.cpp 的 OnDraw 函数 void CChessView::OnDraw(CDC* pDC) { CRect rect; GetClientRect(rect); // 创建内存 DC CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap memBmp; memBmp.CreateCompatibleBitmap(pDC, rect.Width(), rect.Height()); CBitmap* pOldBmp memDC.SelectObject(memBmp); // 先填充背景 memDC.FillSolidRect(rect, RGB(240, 220, 180)); // 画棋盘线 int margin 40; int cellW (rect.Width() - 2 * margin) / (COLS - 1); int cellH (rect.Height() - 2 * margin) / (ROWS - 1); CPen pen(PS_SOLID, 2, RGB(80, 40, 0)); CPen* pOldPen memDC.SelectObject(pen); for (int r 0; r ROWS; r) { memDC.MoveTo(margin, margin r * cellH); memDC.LineTo(margin (COLS - 1) * cellW, margin r * cellH); } for (int c 0; c COLS; c) { memDC.MoveTo(margin c * cellW, margin); memDC.LineTo(margin c * cellW, margin (ROWS - 1) * cellH); } // 九宫斜线 memDC.MoveTo(margin 3 * cellW, margin); memDC.LineTo(margin 5 * cellW, margin 2 * cellH); memDC.MoveTo(margin 5 * cellW, margin); memDC.LineTo(margin 3 * cellW, margin 2 * cellH); memDC.MoveTo(margin 3 * cellW, margin 7 * cellH); memDC.LineTo(margin 5 * cellW, margin 9 * cellH); memDC.MoveTo(margin 5 * cellW, margin 7 * cellH); memDC.LineTo(margin 3 * cellW, margin 9 * cellH); // 画棋子 for (int i 0; i BOARD_SIZE; i) { int piece m_board.cells[i]; if (piece EMPTY) continue; int r rowOf(i), c colOf(i); int x margin c * cellW; int y margin r * cellH; int radius min(cellW, cellH) / 2 - 4; CBrush brush(piece 0 ? RGB(220, 60, 60) : RGB(40, 40, 40)); CBrush* pOldBrush memDC.SelectObject(brush); memDC.Ellipse(x - radius, y - radius, x radius, y radius); memDC.SelectObject(pOldBrush); // 棋子文字 CString text GetPieceText(piece); memDC.SetTextColor(RGB(255, 255, 255)); memDC.SetBkMode(TRANSPARENT); CRect textRect(x - radius, y - radius, x radius, y radius); memDC.DrawText(text, textRect, DT_CENTER | DT_VCENTER | DT_SINGLELINE); } // 一次性拷贝到屏幕 pDC-BitBlt(0, 0, rect.Width(), rect.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); memDC.SelectObject(pOldPen); }margin是棋盘边距cellW和cellH是格宽和格高。注意cellW用(宽度 - 2*margin) / (COLS - 1)而不是除以COLS因为 9 个交叉点之间有 8 个间隔。这个细节如果搞错棋盘右边会多出一截空白。BitBlt的最后一个参数SRCCOPY表示直接拷贝不要用SRCINVERT之类的否则颜色会反。提示如果你用的是 Visual C 2015-2022 运行库环境GDI 的CBitmap和CDC行为一致不需要额外配置。但如果你的程序要分发给别人记得让对方安装对应的 Microsoft Visual C Redistributable否则会报「找不到 mfc140.dll」之类的错误。4.3 鼠标点击转棋盘坐标鼠标点击时要把屏幕坐标转成棋盘索引。用OnLButtonDown获取点击点减去 margin再除以 cellW 和 cellH四舍五入到最近的交叉点。void CChessView::OnLButtonDown(UINT nFlags, CPoint point) { int margin 40; CRect rect; GetClientRect(rect); int cellW (rect.Width() - 2 * margin) / (COLS - 1); int cellH (rect.Height() - 2 * margin) / (ROWS - 1); int c (point.x - margin cellW / 2) / cellW; int r (point.y - margin cellH / 2) / cellH; if (r 0 || r ROWS || c 0 || c COLS) return; int idx indexOf(r, c); // 处理选中和走子逻辑 HandleClick(idx); Invalidate(); // 触发重绘 CView::OnLButtonDown(nFlags, point); }(point.x - margin cellW / 2) / cellW这个写法是四舍五入除法比用round函数更简洁。Invalidate()标记窗口需要重绘但不会立即重绘Windows 会合并多次请求避免频繁刷新。5. 避坑与排查那些让我熬夜的翻车现场5.1 棋子画出来是反的现象红方棋子显示在棋盘上方黑方在下方和实际开局相反。原因棋盘初始化时红方在行 7~9但绘制时y margin r * cellH行号小的在上面。如果你把红方初始化在行 0~2就会反。解决要么改初始化要么在绘制时把行号翻转y margin (ROWS - 1 - r) * cellH。我一般选前者保持数据层和显示层方向一致。5.2 走法生成时数组越界现象程序在生成马的走法时偶尔崩溃调试器指向b.cells[to]。原因nr和nc计算后没有检查边界就直接调indexOf当nr -1时indexOf(-1, c)返回负数访问cells越界。解决在indexOf之前加边界检查或者把indexOf改成返回 -1 表示非法。我习惯在genHorseMoves里先判断nr和nc范围再算to。5.3 炮吃子时跳过多个棋子现象炮隔着两个棋子也能吃子。原因jumped标记为 true 后遇到空格继续扫描遇到第二个棋子时又执行了canLand并push_back没有 break。解决在jumped为 true 且target ! EMPTY时无论是否能吃都要break。因为炮翻山后只能吃第一个碰到的棋子吃不到就不能再往后了。5.4 双缓冲后棋子文字模糊现象用DrawText画棋子上的「车」「马」时文字边缘有锯齿或模糊。原因内存 DC 的位图分辨率和屏幕不一致或者字体没有设置质量。解决创建CFont时用CreatePointFont指定清晰的字号并在memDC.SetBkMode(TRANSPARENT)之后设置SetTextColor。如果还模糊检查memBmp.CreateCompatibleBitmap是否用了屏幕 DC 而不是内存 DC。5.5 程序分发时报缺少运行库现象在自己电脑上跑得好好的发给别人就提示「无法启动此程序因为计算机中丢失 mfc140.dll」。原因Visual C 程序依赖 Microsoft Visual C Redistributable目标机器没装。解决在项目属性里把「C/C → 代码生成 → 运行库」改成「多线程 (/MT)」静态链接运行库这样生成的 exe 不依赖外部 dll。代价是文件变大但分发省心。如果你用的是 Visual C 6.0 这种老版本对应的运行库更老建议直接静态链接。6. 进阶技巧用位棋盘加速走法生成与验证当你把基本功能跑通后如果想让程序能算几步棋走法生成的性能就成了瓶颈。我试过用位棋盘bitboard重写走法生成90 个格用两个 64 位整数表示红方一个、黑方一个走法判断变成位运算速度能快 5 到 10 倍。具体做法是每个格对应一个 bit 位1ULL idx表示该格有棋子。马的走法可以用预计算的掩码表炮的走法需要处理「阻挡」和「翻山」稍微复杂但也能用位运算实现。不过位棋盘不是必须的。如果你只是做一个能下棋的界面程序前面的数组方案足够。只有当你要加 AI 搜索时才值得投入时间优化。验证走法生成是否正确我一般写一个简单的 perft 测试从开局开始枚举 1 层、2 层、3 层的走法总数和已知的标准值对比。中国象棋开局 1 层是 44 个走法2 层是 1920 个3 层是 79666 个。如果你的程序算出来不一样说明走法生成有 bug。// perft 测试递归统计走法数 long long perft(Board b, int depth, bool redTurn) { if (depth 0) return 1; std::vectorMove moves; genAllMoves(b, redTurn, moves); if (depth 1) return moves.size(); long long total 0; for (auto m : moves) { int captured b.cells[m.to]; b.cells[m.to] b.cells[m.from]; b.cells[m.from] EMPTY; total perft(b, depth - 1, !redTurn); b.cells[m.from] b.cells[m.to]; b.cells[m.to] captured; } return total; }这个perft函数会修改棋盘再恢复注意恢复顺序先把from恢复再把to恢复成captured。如果顺序反了from会被覆盖成错误的值。我当初写的时候就是先恢复to再恢复from结果from变成了captured测试数字对不上查了一晚上。最后一个习惯每次改完走法生成先跑 perft 再跑界面。界面能下棋不代表规则全对perft 数字对不上界面下得再流畅也是错的。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑