中国象棋 Visual C++ 程序:从棋盘绘制到走子逻辑的完整落地路径
简介这是一份面向C初学者与Windows桌面开发爱好者的中国象棋完整工程源码基于Visual C与MFC构建可运行人机对弈、人人对弈与机机对弈三种模式帮助读者理解棋类游戏从规则建模到界面交互的完整实现路径。压缩包共112个文件约1.84MB以h头文件与cpp源文件为核心配合obj编译中间文件、ico与bmp图像资源、rc资源脚本及dsw、dsp工程文件另有少量cm棋谱与txt说明结构清晰便于按模块阅读。资源中的人机对弈部分涉及Alpha-Beta剪枝与Minimax搜索、评估函数设计人人对弈侧重MFC窗口控件与事件驱动编程机机对弈则可用于算法性能测试与自我对弈优化同时包含异常处理与调试思路。目前已有220人学习下载适合希望以实战项目掌握C、GUI设计与搜索算法的开发者参考借鉴。1. 中国象棋 Visual C 程序从棋盘绘制到走子逻辑一个桌面端项目的完整落地路径很多人第一次接触 Windows 桌面开发都是从写一个中国象棋程序开始的。它不像商业项目那样庞大但麻雀虽小五脏俱全窗口创建、消息循环、GDI 绘图、鼠标交互、数据结构设计、走法合法性判断、胜负判定一个都跑不掉。用 Visual C 来做这件事优势在于可以直接调用 Win32 API 和 MFC不需要额外运行时依赖编译出来就是一个独立的 exe双击就能跑。这篇文章面向的是有 C 基础、想动手做一个完整桌面程序的开发者不管你是刚学完语法想找个练手项目还是工作多年想重温 Win32 编程的底层逻辑下面这套路径都能让你从零把棋盘画出来、把棋子走起来、把规则判明白。我会按实际开发顺序推进先搭窗口框架再设计棋盘数据结构然后处理鼠标交互和走法校验最后讲几个容易翻车的地方和进阶优化方向。2. 用 Visual C 搭出棋盘窗口从 WinMain 到 GDI 绘制的最小闭环2.1 为什么选 Win32 API 而不是 MFC 或 Qt做中国象棋程序界面部分其实不复杂一个固定大小的窗口画十条横线九条竖线再画三十二个圆形棋子。这种需求用 Win32 API 裸写完全够用而且能让你看清楚 Windows 程序到底是怎么跑起来的。MFC 封装了大量消息映射宏和类继承体系新手容易被DECLARE_MESSAGE_MAP和BEGIN_MESSAGE_MAP绕晕反而忽略了WndProc的本质就是一个大 switch。Qt 当然更现代但引入第三方库会让编译配置变复杂尤其是你只想生成一个绿色 exe 的时候。我一般建议第一个版本用纯 Win32 API把消息循环和 GDI 绘图吃透后面想换框架再说。Visual C 在这里的角色是编译器和 IDE。你用 VS 新建一个「Windows 桌面应用程序」项目选择空项目然后手动添加main.cpp这样不会生成一堆用不上的模板代码。需要注意的是如果你用的是较新版本的 Visual Studio默认字符集是 Unicode所有字符串要用L...宽字符WinMain的签名也要对应改成wWinMain。这个细节不处理好编译会报LNK2019找不到入口点。2.2 创建窗口和消息循环的完整代码下面是一个最小可运行的窗口框架包含注册窗口类、创建窗口、消息循环和窗口过程函数。你可以直接复制到main.cpp里编译运行。#include windows.h // 窗口过程函数声明 LRESULT CALLBACK WndProc(HWND, UINT, WPARAM, LPARAM); // 全局窗口宽度和高度棋盘按 9 列 10 行设计 const int CELL_SIZE 60; // 每个格子边长 60 像素 const int MARGIN 40; // 棋盘四周留白 const int BOARD_W CELL_SIZE * 8 MARGIN * 2; // 8 格宽 两边留白 const int BOARD_H CELL_SIZE * 9 MARGIN * 2; // 9 格高 两边留白 int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, PWSTR, int nCmdShow) { const wchar_t CLASS_NAME[] LChineseChessWnd; WNDCLASS wc {}; wc.lpfnWndProc WndProc; wc.hInstance hInstance; wc.lpszClassName CLASS_NAME; wc.hbrBackground (HBRUSH)(COLOR_WINDOW 1); wc.hCursor LoadCursor(nullptr, IDC_ARROW); RegisterClass(wc); // 创建窗口注意调整窗口大小使客户区正好容纳棋盘 HWND hwnd CreateWindowEx( 0, CLASS_NAME, L中国象棋, WS_OVERLAPPEDWINDOW ~WS_THICKFRAME ~WS_MAXIMIZEBOX, CW_USEDEFAULT, CW_USEDEFAULT, BOARD_W 16, BOARD_H 39, // 加上边框和标题栏的大致高度 nullptr, nullptr, hInstance, nullptr ); if (!hwnd) return 0; ShowWindow(hwnd, nCmdShow); UpdateWindow(hwnd); MSG msg {}; while (GetMessage(msg, nullptr, 0, 0)) { TranslateMessage(msg); DispatchMessage(msg); } return 0; }这段代码的关键点有三个。第一WNDCLASS里的hbrBackground决定了窗口背景色用COLOR_WINDOW 1是系统默认白色后面我们在WM_PAINT里会自己画棋盘所以背景色其实会被覆盖。第二CreateWindowEx的宽高参数需要把窗口边框和标题栏算进去否则客户区会比你预期的窄棋盘右边会被裁掉。不同 Windows 版本的边框厚度不一样稳妥做法是在WM_CREATE里用AdjustWindowRect动态计算。第三消息循环里的GetMessage返回 0 时退出这是标准写法不要改成while(1)然后自己判断WM_QUIT那样容易漏消息。2.3 在 WM_PAINT 里画出棋盘和棋子窗口过程函数是核心所有绘图和交互都在这里处理。先看棋盘绘制部分。LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_PAINT: { PAINTSTRUCT ps; HDC hdc BeginPaint(hwnd, ps); // 双缓冲先在内存 DC 上画再一次性拷贝到屏幕避免闪烁 HDC memDC CreateCompatibleDC(hdc); HBITMAP memBmp CreateCompatibleBitmap(hdc, BOARD_W, BOARD_H); HBITMAP oldBmp (HBITMAP)SelectObject(memDC, memBmp); // 填充背景色 RECT rc { 0, 0, BOARD_W, BOARD_H }; FillRect(memDC, rc, (HBRUSH)GetStockObject(WHITE_BRUSH)); // 画棋盘线条 HPEN pen CreatePen(PS_SOLID, 2, RGB(0, 0, 0)); HPEN oldPen (HPEN)SelectObject(memDC, pen); // 画 10 条横线 for (int i 0; i 10; i) { int y MARGIN i * CELL_SIZE; MoveToEx(memDC, MARGIN, y, nullptr); LineTo(memDC, MARGIN 8 * CELL_SIZE, y); } // 画 9 条竖线中间楚河汉界处断开 for (int j 0; j 9; j) { int x MARGIN j * CELL_SIZE; if (j 0 || j 8) { // 最左和最右的竖线是完整的 MoveToEx(memDC, x, MARGIN, nullptr); LineTo(memDC, x, MARGIN 9 * CELL_SIZE); } else { // 中间的竖线在楚河汉界处断开上半部分 5 行下半部分 5 行 MoveToEx(memDC, x, MARGIN, nullptr); LineTo(memDC, x, MARGIN 4 * CELL_SIZE); MoveToEx(memDC, x, MARGIN 5 * CELL_SIZE, nullptr); LineTo(memDC, x, MARGIN 9 * CELL_SIZE); } } SelectObject(memDC, oldPen); DeleteObject(pen); // 把内存 DC 的内容拷贝到窗口 DC BitBlt(hdc, 0, 0, BOARD_W, BOARD_H, memDC, 0, 0, SRCCOPY); SelectObject(memDC, oldBmp); DeleteObject(memBmp); DeleteDC(memDC); EndPaint(hwnd, ps); return 0; } case WM_DESTROY: PostQuitMessage(0); return 0; } return DefWindowProc(hwnd, msg, wParam, lParam); }这里用了双缓冲技术这是 Win32 GDI 绘图防闪烁的标准做法。如果你直接在窗口 DC 上画每次WM_PAINT触发时屏幕会先擦除背景再画线肉眼能看到明显闪烁。双缓冲的原理是创建一个和窗口兼容的内存位图所有绘图操作先在内存里完成最后用BitBlt一次性拷贝到屏幕。CreateCompatibleDC创建的内存 DC 初始只有 1x1 像素必须用CreateCompatibleBitmap选入一个正确尺寸的位图才能画。棋盘的线条布局要注意楚河汉界中间第 5 行和第 6 行之间是河界竖线在这里断开但最左和最右两条边线不断。横线一共 10 条竖线 9 条交叉点就是棋子可以落子的位置共 90 个点。棋子的绘制我放在下一章讲因为那涉及到棋子数据的管理。3. 棋盘数据结构与棋子管理用二维数组还是对象列表3.1 棋盘表示9x10 二维数组是最直接的选择中国象棋棋盘是 9 列 10 行正好可以用一个int board[10][9]来表示。数组下标board[row][col]对应棋盘上的交叉点row从 0 到 9 从上到下col从 0 到 8 从左到右。数组里存什么最简单的做法是存整数编码0 表示空位正数表示红方棋子负数表示黑方棋子绝对值对应棋子类型。比如 1 是帅2 是仕3 是相4 是马5 是车6 是炮7 是兵黑方就是 -1 到 -7。这种编码方式的好处是判断敌我只需要看符号判断类型只需要看绝对值走法生成时非常方便。另一种做法是定义一个Piece结构体包含颜色、类型、位置等字段然后用std::vectorPiece管理。对象列表更面向对象但每次查找某个位置有没有棋子都要遍历列表效率低。棋盘只有 90 个位置二维数组的内存占用可以忽略不计而且随机访问是 O(1)我强烈建议第一个版本用二维数组。// 棋子类型枚举 enum PieceType { EMPTY 0, SHUAI 1, // 帅/将 SHI 2, // 仕/士 XIANG 3, // 相/象 MA 4, // 马 CHE 5, // 车 PAO 6, // 炮 BING 7 // 兵/卒 }; // 棋盘数组正数红方负数黑方0 空位 int board[10][9]; // 初始化棋盘按照标准开局摆放棋子 void InitBoard() { memset(board, 0, sizeof(board)); // 黑方底线第 0 行 board[0][0] -CHE; board[0][1] -MA; board[0][2] -XIANG; board[0][3] -SHI; board[0][4] -SHUAI; board[0][5] -SHI; board[0][6] -XIANG; board[0][7] -MA; board[0][8] -CHE; // 黑方炮位第 2 行 board[2][1] -PAO; board[2][7] -PAO; // 黑方兵位第 3 行每隔一列一个 for (int j 0; j 9; j 2) board[3][j] -BING; // 红方兵位第 6 行 for (int j 0; j 9; j 2) board[6][j] BING; // 红方炮位第 7 行 board[7][1] PAO; board[7][7] PAO; // 红方底线第 9 行 board[9][0] CHE; board[9][1] MA; board[9][2] XIANG; board[9][3] SHI; board[9][4] SHUAI; board[9][5] SHI; board[9][6] XIANG; board[9][7] MA; board[9][8] CHE; }这段初始化代码把标准开局摆好了。注意黑方在上、红方在下这是中国象棋的惯例。memset把整个数组清零然后逐个赋值。兵和卒的位置是隔一列放一个所以循环步长是 2。炮在第二条横线和第七条横线分别位于第 2 列和第 8 列下标 1 和 7。3.2 棋子绘制用椭圆和文字输出有了棋盘数据绘制棋子就是遍历数组遇到非零值就在对应位置画一个圆里面写上棋子名称。GDI 画圆用Ellipse写字用TextOut或DrawText。// 根据棋子编码获取显示文字 const wchar_t* GetPieceText(int piece) { switch (abs(piece)) { case SHUAI: return piece 0 ? L帅 : L将; case SHI: return piece 0 ? L仕 : L士; case XIANG: return piece 0 ? L相 : L象; case MA: return L马; case CHE: return L车; case PAO: return L炮; case BING: return piece 0 ? L兵 : L卒; } return L; } // 在内存 DC 上绘制所有棋子 void DrawPieces(HDC memDC) { int radius CELL_SIZE / 2 - 6; // 棋子半径留一点边距 for (int row 0; row 10; row) { for (int col 0; col 9; col) { int piece board[row][col]; if (piece 0) continue; int cx MARGIN col * CELL_SIZE; // 圆心 x int cy MARGIN row * CELL_SIZE; // 圆心 y // 红方用红色边框黑方用黑色边框 COLORREF color (piece 0) ? RGB(200, 0, 0) : RGB(0, 0, 0); HPEN pen CreatePen(PS_SOLID, 2, color); HPEN oldPen (HPEN)SelectObject(memDC, pen); // 棋子内部填充浅色 HBRUSH brush CreateSolidBrush(RGB(255, 245, 220)); HBRUSH oldBrush (HBRUSH)SelectObject(memDC, brush); Ellipse(memDC, cx - radius, cy - radius, cx radius, cy radius); SelectObject(memDC, oldPen); SelectObject(memDC, oldBrush); DeleteObject(pen); DeleteObject(brush); // 绘制文字居中显示 SetTextColor(memDC, color); SetBkMode(memDC, TRANSPARENT); const wchar_t* text GetPieceText(piece); RECT textRect { cx - radius, cy - radius, cx radius, cy radius }; DrawText(memDC, text, -1, textRect, DT_CENTER | DT_VCENTER | DT_SINGLELINE); } } }DrawText的DT_CENTER | DT_VCENTER | DT_SINGLELINE组合能让文字在矩形内水平和垂直居中。SetBkMode(TRANSPARENT)很重要否则文字背景会覆盖棋子填充色。棋子的颜色区分红方用深红色边框和文字黑方用黑色。填充色统一用浅米色这样看起来像木质棋子。把DrawPieces调用加到WM_PAINT里BitBlt之前棋盘和棋子就都显示出来了。编译运行你应该能看到一个完整的中国象棋开局界面。4. 鼠标交互与走法校验让棋子真正动起来4.1 鼠标点击选中与落子坐标转换和状态机棋盘画好了接下来要让用户能点击棋子并移动。鼠标消息是WM_LBUTTONDOWNlParam里包含点击坐标低 16 位是 x高 16 位是 y。我们需要把屏幕坐标转换成棋盘的行列下标。// 全局变量记录当前选中的棋子位置 int selectedRow -1; int selectedCol -1; // 把鼠标坐标转换为棋盘行列返回 false 表示点击在棋盘外 bool ScreenToBoard(int mx, int my, int row, int col) { // 计算最近的交叉点 int c (mx - MARGIN CELL_SIZE / 2) / CELL_SIZE; int r (my - MARGIN CELL_SIZE / 2) / CELL_SIZE; if (r 0 || r 9 || c 0 || c 8) return false; // 检查点击位置是否在交叉点附近避免误触 int cx MARGIN c * CELL_SIZE; int cy MARGIN r * CELL_SIZE; int dx mx - cx; int dy my - cy; if (dx * dx dy * dy (CELL_SIZE / 2) * (CELL_SIZE / 2)) return false; row r; col c; return true; }ScreenToBoard先算出最近的交叉点行列然后判断点击位置到交叉点的距离是否在棋子半径范围内。如果超出说明点在了空白处返回 false。这个距离判断用平方比较避免开方运算。选中和落子的逻辑用一个简单的状态机第一次点击选中己方棋子第二次点击如果目标位置合法就移动如果点的是另一个己方棋子就切换选中如果点的是非法位置就取消选中。case WM_LBUTTONDOWN: { int mx LOWORD(lParam); int my HIWORD(lParam); int row, col; if (!ScreenToBoard(mx, my, row, col)) break; int piece board[row][col]; if (selectedRow -1) { // 还没有选中任何棋子尝试选中当前点击的棋子 if (piece ! 0) { selectedRow row; selectedCol col; InvalidateRect(hwnd, nullptr, FALSE); // 触发重绘高亮选中棋子 } } else { // 已经选中了一个棋子判断这次点击是移动还是切换选中 if (piece ! 0 ((piece 0) (board[selectedRow][selectedCol] 0))) { // 点击的是己方另一个棋子切换选中 selectedRow row; selectedCol col; InvalidateRect(hwnd, nullptr, FALSE); } else { // 尝试移动 if (IsValidMove(selectedRow, selectedCol, row, col)) { board[row][col] board[selectedRow][selectedCol]; board[selectedRow][selectedCol] 0; selectedRow -1; selectedCol -1; InvalidateRect(hwnd, nullptr, FALSE); } else { // 非法移动取消选中 selectedRow -1; selectedCol -1; InvalidateRect(hwnd, nullptr, FALSE); } } } return 0; }InvalidateRect的第三个参数FALSE表示不擦除背景因为我们用了双缓冲整个客户区都会被重绘不需要系统再擦一遍。选中状态的高亮可以在DrawPieces里加一个判断如果当前行列等于selectedRow和selectedCol就用更粗的边框或者不同颜色画。4.2 走法合法性判断每种棋子的规则拆解IsValidMove是整个程序最核心的函数需要判断从(fromRow, fromCol)到(toRow, toCol)的移动是否符合中国象棋规则。规则按棋子类型分车CHE直线移动路径上不能有棋子。判断方法是如果行相同检查列之间是否有非零值如果列相同检查行之间是否有非零值。马MA走日字即行变化 2 列变化 1或者行变化 1 列变化 2。但马腿会被蹩住如果行变化 2则中间那一行同列的位置不能有棋子如果列变化 2则中间那一列同行的位置不能有棋子。炮PAO走法和车一样是直线但吃子时必须隔一个棋子炮架。也就是说如果目标位置为空路径上不能有任何棋子如果目标位置有敌方棋子路径上必须恰好有一个棋子。兵/卒BING只能向前走一步。红方兵向上行号减小黑方卒向下行号增大。过河后可以左右移动。红方过河是行号小于等于 4黑方过河是行号大于等于 5。相/象XIANG走田字即行变化 2 列变化 2。不能过河红方相的行号必须大于等于 5黑方象的行号必须小于等于 4。田字中心不能有棋子塞象眼。仕/士SHI走斜线一步即行变化 1 列变化 1。只能在九宫格内移动红方九宫格是行 7-9 列 3-5黑方是行 0-2 列 3-5。帅/将SHUAI走直线一步即行变化 1 列变化 0或者行变化 0 列变化 1。同样只能在九宫格内。另外还有「将帅对面」规则如果帅和将在同一列且中间没有其他棋子走成这种局面是违规的。bool IsValidMove(int fr, int fc, int tr, int tc) { if (fr tr fc tc) return false; // 原地不动 int piece board[fr][fc]; int target board[tr][tc]; // 不能吃己方棋子 if (target ! 0 ((piece 0) (target 0))) return false; int type abs(piece); int dr tr - fr; int dc tc - fc; switch (type) { case CHE: return IsStraightClear(fr, fc, tr, tc); case MA: { if (!((abs(dr) 2 abs(dc) 1) || (abs(dr) 1 abs(dc) 2))) return false; // 检查马腿 if (abs(dr) 2) { int legRow fr (dr 0 ? 1 : -1); if (board[legRow][fc] ! 0) return false; } else { int legCol fc (dc 0 ? 1 : -1); if (board[fr][legCol] ! 0) return false; } return true; } case PAO: { if (fr ! tr fc ! tc) return false; // 必须直线 int count CountBetween(fr, fc, tr, tc); if (target 0) return count 0; // 移动时不能有棋子挡路 else return count 1; // 吃子时必须有且只有一个炮架 } case BING: { if (piece 0) // 红兵 { if (dr -1 dc 0) return true; // 向前 if (fr 4 dr 0 abs(dc) 1) return true; // 过河后左右 } else // 黑卒 { if (dr 1 dc 0) return true; if (fr 5 dr 0 abs(dc) 1) return true; } return false; } case XIANG: { if (abs(dr) ! 2 || abs(dc) ! 2) return false; // 不能过河 if (piece 0 tr 5) return false; if (piece 0 tr 4) return false; // 塞象眼 int eyeRow fr dr / 2; int eyeCol fc dc / 2; if (board[eyeRow][eyeCol] ! 0) return false; return true; } case SHI: { if (abs(dr) ! 1 || abs(dc) ! 1) return false; // 九宫格限制 if (tc 3 || tc 5) return false; if (piece 0 (tr 7 || tr 9)) return false; if (piece 0 (tr 0 || tr 2)) return false; return true; } case SHUAI: { if (!((abs(dr) 1 dc 0) || (dr 0 abs(dc) 1))) return false; // 九宫格限制 if (tc 3 || tc 5) return false; if (piece 0 (tr 7 || tr 9)) return false; if (piece 0 (tr 0 || tr 2)) return false; return true; } } return false; }辅助函数IsStraightClear判断直线路径上是否有棋子CountBetween统计两点之间的棋子数量。这两个函数逻辑类似可以合并成一个返回计数的函数然后IsStraightClear就是CountBetween(...) 0。// 统计 (fr,fc) 到 (tr,tc) 之间不含两端的棋子数量 int CountBetween(int fr, int fc, int tr, int tc) { int count 0; if (fr tr) { int minC min(fc, tc), maxC max(fc, tc); for (int c minC 1; c maxC; c) if (board[fr][c] ! 0) count; } else if (fc tc) { int minR min(fr, tr), maxR max(fr, tr); for (int r minR 1; r maxR; r) if (board[r][fc] ! 0) count; } return count; }这个函数只处理同行或同列的情况因为所有直线走子都满足这个前提。如果行列都不同返回 0但调用方已经做了直线判断不会走到这里。4.3 将帅对面与胜负判定将帅对面规则需要单独检查如果帅和将在同一列且中间没有任何棋子那么走成这个局面是违规的。实现方式是在IsValidMove返回 true 之前模拟走子然后检查将帅是否对面。bool IsKingFacing() { int redRow -1, redCol -1; int blackRow -1, blackCol -1; for (int r 0; r 10; r) for (int c 0; c 9; c) { if (board[r][c] SHUAI) { redRow r; redCol c; } if (board[r][c] -SHUAI) { blackRow r; blackCol c; } } if (redCol ! blackCol) return false; // 同一列检查中间是否有棋子 int minR min(redRow, blackRow); int maxR max(redRow, blackRow); for (int r minR 1; r maxR; r) if (board[r][redCol] ! 0) return false; return true; }在WM_LBUTTONDOWN的移动逻辑里先保存目标位置的原值执行移动然后调用IsKingFacing()如果返回 true 就撤销移动。胜负判定更简单如果某一方的帅/将被吃掉游戏结束。你可以在每次移动后检查棋盘上是否还存在SHUAI和-SHUAI缺少哪个就哪方输。5. 避坑与排查中国象棋 Visual C 程序最常见的 5 个翻车点5.1 棋子画出来是椭圆不是圆现象Ellipse画出来的棋子看起来扁扁的不是正圆。原因Ellipse的参数是外接矩形的左上角和右下角坐标如果宽度和高度不相等画出来就是椭圆。常见错误是半径计算时用了CELL_SIZE / 2但没减边距或者 x 和 y 方向用了不同的半径值。解决确保cx - radius到cx radius的宽度等于cy - radius到cy radius的高度。radius用一个统一值比如CELL_SIZE / 2 - 6。另外检查CELL_SIZE是不是奇数奇数会导致除不尽建议用偶数如 60。5.2 点击棋子没反应或者选中的是旁边的棋子现象鼠标明明点在棋子上但程序没选中或者选中了相邻位置的棋子。原因坐标转换时没有做距离判断或者距离阈值设得太大。ScreenToBoard里如果只算最近交叉点而不检查距离点击棋盘空白处也会被当成有效点击。另外MARGIN和CELL_SIZE的数值如果和绘图时不一致坐标就会偏移。解决在ScreenToBoard里加上距离平方判断阈值设为(CELL_SIZE / 2) * (CELL_SIZE / 2)。确保绘图和点击转换用的是同一组MARGIN和CELL_SIZE常量不要一处用宏一处用字面量。5.3 走法校验漏掉了炮的吃子规则现象炮可以直接吃子不需要炮架或者炮移动时被棋子挡住也能走。原因炮的规则和车不同车是路径无子可走可吃炮是路径无子只能走、路径有且仅有一子才能吃。很多人在IsValidMove里把炮和车合并处理导致规则错误。解决炮单独一个 case用CountBetween统计路径棋子数。目标为空时要求计数为 0目标有子时要求计数为 1。注意CountBetween不包含起点和终点所以炮架在中间任意位置都能被统计到。5.4 将帅对面没有检测导致可以走成违规局面现象帅和将走到同一列且中间无子游戏没有提示违规。原因IsValidMove只检查了棋子本身的走法没有检查走完之后全局局面是否合法。解决在移动执行后调用IsKingFacing()如果返回 true 就撤销移动。撤销的方法是保存目标位置的原值移动后如果发现违规把目标位置恢复原值起点恢复移动的棋子。5.5 窗口重绘时闪烁严重现象每次点击棋子整个窗口闪一下体验很差。原因没有用双缓冲WM_PAINT里直接往窗口 DC 画系统先擦背景再画内容中间有个空白瞬间。解决用CreateCompatibleDC和CreateCompatibleBitmap创建内存 DC所有绘图在内存 DC 完成最后BitBlt到窗口 DC。注意CreateCompatibleBitmap要用窗口 DC 作为参数这样位图格式才匹配。内存 DC 和位图用完要DeleteDC和DeleteObject否则会内存泄漏。6. 进阶技巧用 Alpha-Beta 剪枝给象棋程序加一个简单 AI前面做的都是人人对战如果你想跟电脑下就需要一个 AI 引擎。最简单的 AI 是极小极大搜索加 Alpha-Beta 剪枝搜索深度 3 到 4 层就能有不错的效果。核心思路是评估函数给每个局面打分红方优势为正黑方优势为负搜索时红方选最大分黑方选最小分Alpha-Beta 剪枝在发现某个分支不可能影响最终结果时提前终止把搜索量从指数级降下来。评估函数可以很简单车 100 分马 45 分炮 45 分相 20 分仕 20 分兵 10 分帅 10000 分。再加上位置分比如兵过河后加分马在中心加分。搜索时用递归实现每一层生成所有合法走法逐个尝试递归到下一层。// 简单的局面评估函数 int Evaluate() { int score 0; int values[] { 0, 10000, 20, 20, 45, 100, 45, 10 }; // 对应 SHUAI 到 BING for (int r 0; r 10; r) for (int c 0; c 9; c) { int piece board[r][c]; if (piece 0) continue; int type abs(piece); int val values[type]; // 兵过河加分 if (type BING) { if (piece 0 r 4) val 10; if (piece 0 r 5) val 10; } score (piece 0) ? val : -val; } return score; } // Alpha-Beta 搜索depth 是剩余深度alpha 和 beta 是剪枝边界 int AlphaBeta(int depth, int alpha, int beta, bool isRed) { if (depth 0) return Evaluate(); // 生成所有合法走法这里简化处理实际需要遍历所有棋子 // 伪代码for each move: // 执行走法 // int val AlphaBeta(depth - 1, alpha, beta, !isRed); // 撤销走法 // if (isRed) { alpha max(alpha, val); if (alpha beta) break; } // else { beta min(beta, val); if (beta alpha) break; } // 返回 isRed ? alpha : beta return isRed ? alpha : beta; }实际实现时走法生成需要遍历棋盘上所有己方棋子对每个棋子尝试所有可能的目标位置用IsValidMove过滤。搜索深度 3 大约需要几毫秒深度 4 可能需要几十毫秒深度 5 就有点慢了。你可以加一个走法排序把吃子的走法排在前面这样剪枝效率更高。另一个优化是缓存已经搜索过的局面用哈希表记录局面特征和对应的分数避免重复计算。局面特征可以用 Zobrist 哈希给每个棋子在每个位置分配一个随机数异或起来作为键值。这个技巧在棋类引擎里很常见实现起来也不复杂。我自己写象棋程序最大的教训是不要一开始就追求 AI 有多强先把人人对战的规则做对把界面做流畅把走法校验的边界情况都测一遍。我见过太多人规则还没写全就急着加搜索结果 AI 走出的棋都是违规的调试起来非常痛苦。另外Visual C 项目建议用 Unicode 字符集所有字符串用L...这样中文显示不会乱码。如果你在旧版本 Visual C 6.0 上开发注意它默认是 MBCS需要手动改项目属性但那个版本太老了建议直接用 Visual Studio 2019 或 2022社区版免费编译器和调试器都好用得多。希望帮到你。本文还有配套的精品资源点击获取