☰
中国象棋 Visual C++ 程序:从棋盘绘制到走子逻辑的完整落地路径
2026/10/25 16:25:08 网站建设 项目流程

简介:这是一份面向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[] = L"ChineseChessWnd"; 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::vector<Piece>管理。对象列表更面向对象,但每次查找某个位置有没有棋子都要遍历列表,效率低。棋盘只有 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_LBUTTONDOWN,lParam里包含点击坐标,低 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,社区版免费,编译器和调试器都好用得多。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询