简介:基于Windows SDK的象棋程序开发源码资源,面向学习Windows原生API编程与游戏逻辑实现的开发者,提供可直接编译的示例工程。资源共38个文件,压缩后仅24KB,包含头文件(h)、源文件(cpp)与资源文件(rc/ico/cur),覆盖窗口创建、界面绘制、消息处理、象棋规则判断等核心模块,适合初涉Windows SDK或对棋类AI感兴趣的程序员参考学习。当前已有123人学习下载。通过该资源可了解如何利用GDI绘制棋盘、实现鼠标拖放棋子、设计棋盘数据结构,并借助调试工具完善项目,是一份紧凑且结构清晰的入门级实战代码。
1. 用 Windows SDK 写象棋:没有框架拖累的桌面开发入门课
很多人想学 Windows 桌面开发,一上来就钻进 MFC、Qt、C# 的怀抱,结果被信号槽、消息映射、依赖库绕得头晕。用 Windows SDK(也就是 Win32 API)从零写一个中国象棋,恰恰能把窗口系统的底层机制一层层剥开看清楚。这个项目做的事不复杂:创建一个原生窗口,用 GDI 画一个九宫棋盘,再让你的鼠标能拾取棋子、按规则走棋。它适合刚学完 C 语言、想搞懂窗口程序到底怎么转起来的同学,也适合做过上位机但被界面库折磨过的工程师。整个方案不依赖任何第三方库,一个 .cpp 文件加一个 .rc 文件就能编译。更重要的是,象棋的规则足够标准,你写出来的走法判断和 AI 搜索,以后搬到任何语言都成立。
2. 先用 Win32 搭出窗口骨架:从注册类到消息循环的 60 行最小工程
2.1 为什么选择 Win32 而不是高层框架
写一个象棋程序,界面部分其实只有棋盘和棋子,没有按钮、输入框、列表这类复杂控件。Win32 自带的窗口过程虽然古老,但应付这种固定区域的自绘界面反而比任何框架都直接。你用 Qt 要建一个 QWidget 子类,重写 paintEvent,处理 mousePressEvent,虽然方便,但底层发生什么你未必清楚。用 Win32,你会发现窗口的诞生、消息的流动、重绘的触发全都在你眼前,调试起来也简单——一个 GetLastError 就能解决问题。
另一个理由是部署成本。Windows SDK 是系统组件,编译器只要装了 C++ 桌面开发那个 workload,直接 cl.exe 或者用 Visual Studio 新建“Windows 桌面应用程序”就能跑。不需要额外的运行时,产出一个 50 KB 的 EXE 往别的电脑一拷就运行。这种克制的工程体验,在做小工具和内部测试程序时尤其舒服。
2.2 注册窗口类与创建主窗口
任何 Win32 程序的第一步都是注册一个窗口类,告诉系统窗口过程函数是谁、光标和图标长什么样。下面是象棋程序最小入口,我保留了完整错误检查,但篇幅已经压得很短。
#include <windows.h> LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp); int WINAPI WinMain(HINSTANCE hInst, HINSTANCE hPrev, LPSTR cmdLine, int nShow) { const wchar_t* CLASS_NAME = L"ChineseChessWindow"; WNDCLASS wc = {}; wc.lpfnWndProc = WndProc; wc.hInstance = hInst; wc.lpszClassName = CLASS_NAME; wc.hCursor = LoadCursor(NULL, IDC_ARROW); wc.hbrBackground = (HBRUSH)(COLOR_WINDOW + 1); if (!RegisterClass(&wc)) return 1; HWND hwnd = CreateWindowEx( 0, CLASS_NAME, L"中国象棋 - Win32 SDK", WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 600, 600, NULL, NULL, hInst, NULL); if (!hwnd) return 1; ShowWindow(hwnd, nShow); UpdateWindow(hwnd); return RunMessageLoop(); }这段代码里最关键的是WNDCLASS结构:lpfnWndProc是让系统回调你的窗口过程,hbrBackground给了窗口一个底色,这样重绘时先刷掉旧内容。CreateWindowEx的WS_OVERLAPPEDWINDOW表示标准可缩放窗口,但象棋棋盘固定尺寸,你完全可以用WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX把边框锁死。窗口大小我故意设成 600×600,后面绘制棋盘时再按客户区大小自适应。
RunMessageLoop是我省略掉的消息循环,下一节补上。注意WinMain的参数nShow要传给ShowWindow,否则窗口可能以最小化启动。
2.3 消息循环与窗口过程的最小骨架
消息循环是整个窗口程序的发动机。GetMessage从队列里取消息,TranslateMessage处理键盘按键转为字符消息,DispatchMessage把消息发送给窗口过程。只要没有 WM_QUIT,循环就不停。
int RunMessageLoop() { MSG msg; while (GetMessage(&msg, NULL, 0, 0)) { TranslateMessage(&msg); DispatchMessage(&msg); } return (int)msg.wParam; } LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { switch (msg) { case WM_DESTROY: PostQuitMessage(0); return 0; case WM_PAINT: // 棋盘绘制后来在这里实现 return 0; case WM_LBUTTONDOWN: // 鼠标拾取后来在这里实现 return 0; } return DefWindowProc(hwnd, msg, wp, lp); }窗口过程是 Win32 程序的黑匣子入口:你只负责处理关心的消息,其余一律交给DefWindowProc。这里有三个容易翻车的地方。第一,WM_PAINT里如果用GetDC而不是BeginPaint,窗口的最小化恢复后棋盘会闪怪。第二,WM_LBUTTONDOWN的lp参数不是坐标指针,需要用LOWORD(lp)和HIWORD(lp)拆出 x/y,这个和很多人想当然的不一样。第三,如果消息循环里某次处理超过 500ms,拖拽窗口时系统会直接弹“未响应”,所以后面 AI 搜索我们放线程里或者限深。
到这里,一个空窗口已经能占满屏幕但啥也不画。接下来要干的正事是画棋盘。
3. 棋盘与棋子的绘制:坐标换算和双缓冲是关键
3.1 棋盘坐标与九宫换算
中国象棋棋盘是 9 列 × 10 行,但交叉点才是落子位置,所以画线要画横 10 条、竖 9 条,形成 9×9 的内部网格。先定义一套全局常量,让绘制和鼠标拾取共用同一套坐标换算,避免各写各的导致点击偏差。
#define BOARD_COLS 9 #define BOARD_ROWS 10 #define CELL_SIZE 42 // 每个格子的像素边长 #define MARGIN 40 // 棋盘左边和上边的留白 // 将逻辑坐标 (col,row) 换算为屏幕坐标 int colToX(int col) { return MARGIN + col * CELL_SIZE; } int rowToY(int row) { return MARGIN + row * CELL_SIZE; } // 将屏幕坐标换算为逻辑坐标,超出范围返回 -1 int xToCol(int x) { int c = (x - MARGIN + CELL_SIZE/2) / CELL_SIZE; return (c >= 0 && c < BOARD_COLS) ? c : -1; } int yToRow(int y) { int r = (y - MARGIN + CELL_SIZE/2) / CELL_SIZE; return (r >= 0 && r < BOARD_ROWS) ? r : -1; }这几行是整篇程序的地基,也是后面所有坑的来源。CELL_SIZE取 42 是因为它是偶数,居中计算方便。xToCol里加CELL_SIZE/2起到四舍五入的作用,让你点在格子的边缘也能正确落到最近的交叉点。注意棋盘上方要留出比MARGIN更大的空间,因为棋子是圆形汉字,顶部文字出界不好看,我实际用MARGIN=44,第一行棋子和窗口边缘留 2 像素呼吸感。
3.2 用 GDI 画网格、河界和炮位
GDI 画线是老规矩:MoveToEx定起点,LineTo画到终点。棋盘竖线在河界处要断开,横线直接连通。另外炮位附近要画四个小十字,表示“没有连续直线”的炮位标记。
void DrawBoard(HDC hdc) { HPEN pen = CreatePen(PS_SOLID, 1, RGB(80, 60, 40)); HGDIOBJ old = SelectObject(hdc, pen); // 画横线:顶部到底部,共 10 条 for (int r = 0; r < BOARD_ROWS; r++) { MoveToEx(hdc, colToX(0), rowToY(r), NULL); LineTo(hdc, colToX(BOARD_COLS-1), rowToY(r)); } // 画竖线:左、右边界和中间部分,河界断开 for (int c = 0; c < BOARD_COLS; c++) { int x = colToX(c); // 上半部分 MoveToEx(hdc, x, rowToY(0), NULL); LineTo(hdc, x, rowToY(4)); // 下半部分 MoveToEx(hdc, x, rowToY(5), NULL); LineTo(hdc, x, rowToY(BOARD_ROWS-1)); } // 炮位标记:每个炮位画一个小十字 int cannons[4][2] = {{1,2},{7,2},{1,7},{7,7}}; for (int i = 0; i < 4; i++) { int x = colToX(cannons[i][0]); int y = rowToY(cannons[i][1]); MoveToEx(hdc, x-6, y, NULL); LineTo(hdc, x+6, y); MoveToEx(hdc, x, y-6, NULL); LineTo(hdc, x, y+6); } SelectObject(hdc, old); DeleteObject(pen); }横线画 10 条,竖线只画左右两条整根,中间 7 条都断在河界处,这是象棋棋盘的标准画法。注意我用了CreatePen并最后DeleteObject,有人图省事用GetStockObject(BLACK_PEN)但颜色受限。棋盘的颜色选深棕色比纯黑更有传统质感,当然这是个人审美。
3.3 双缓冲与中文棋子绘制
如果直接在窗口的 DC 上绘图,每次WM_PAINT重画整个棋盘时,窗口会像旧电视一样闪烁。特别是拖动窗口边缘时,底色交替漏出令人抓狂。解决方法是把整幅棋盘先画到内存位图,再一次BitBlt拷贝到屏幕。
void PaintChessboard(HWND hwnd) { PAINTSTRUCT ps; HDC hdc = BeginPaint(hwnd, &ps); RECT rc; GetClientRect(hwnd, &rc); int width = rc.right - rc.left; int height = rc.bottom - rc.top; // 创建内存 DC 和位图,作为双缓冲 HDC memDC = CreateCompatibleDC(hdc); HBITMAP memBmp = CreateCompatibleBitmap(hdc, width, height); HGDIOBJ oldBmp = SelectObject(memDC, memBmp); // 1. 清底 FillRect(memDC, &rc, (HBRUSH)(COLOR_WINDOW+1)); // 2. 画棋盘 DrawBoard(memDC); // 3. 画棋子(下一章有 DrawPiece) // DrawAllPieces(memDC); // 4. 一次拷贝到屏幕 BitBlt(hdc, 0, 0, width, height, memDC, 0, 0, SRCCOPY); SelectObject(memDC, oldBmp); DeleteObject(memBmp); DeleteDC(memDC); EndPaint(hwnd, &ps); }双缓冲的原理是用一个内存 DC 作为画布,所有的MoveToEx、LineTo、Ellipse都是画在内存里,最后BitBlt把内存整块推给屏幕。这里有两个细节容易踩:位图必须和窗口客户区大小一致,否则BitBlt只拷贝部分;CreateCompatibleBitmap用的是屏幕 DC,如果误传了内存 DC 会导致位图颜色格式不对——这句是血泪经验,我早年翻过车。
绘制棋子时,我在memDC上画一个圆,然后用SetBkMode(memDC, TRANSPARENT)再TextOut写汉字。汉字需要创建宋体或楷体字体,字符集必须设GB2312_CHARSET,否则中文会出现乱码。
void DrawPiece(HDC hdc, int col, int row, wchar_t ch, BOOL red) { int x = colToX(col); int y = rowToY(row); // 画棋子底色和边框 HBRUSH brush = CreateSolidBrush(red ? RGB(230, 50, 50) : RGB(240, 240, 220)); HPEN pen = CreatePen(PS_SOLID, 2, RGB(80, 60, 40)); HGDIOBJ oldPen = SelectObject(hdc, pen); HGDIOBJ oldBrush = SelectObject(hdc, brush); Ellipse(hdc, x-18, y-18, x+18, y+18); // 写字 HFONT font = CreateFont(24, 0, 0, 0, FW_BOLD, 0, 0, 0, GB2312_CHARSET, 0, 0, 0, 0, L"SimSun"); HGDIOBJ oldFont = SelectObject(hdc, font); SetBkMode(hdc, TRANSPARENT); SetTextColor(hdc, red ? RGB(200, 0, 0) : RGB(30, 30, 30)); RECT rc = { x-16, y-16, x+16, y+16 }; DrawText(hdc, &ch, 1, &rc, DT_CENTER | DT_VCENTER | DT_SINGLELINE); SelectObject(hdc, oldFont); DeleteObject(font); SelectObject(hdc, oldPen); SelectObject(hdc, oldBrush); DeleteObject(pen); DeleteObject(brush); }DrawText比TextOut更适合居中,因为DT_CENTER | DT_VCENTER能让汉字在 32×32 的正方形里正好居中。绘制棋子要在双缓冲的memDC上进行,不然还是会闪。字体每画一个棋子就创建一次,性能没问题,但如果你批量绘制几十个棋子,建议在WM_CREATE里创建字体存成全局变量,避免频繁创建销毁。
4. 走棋规则:从棋子数据结构到胜负判定
4.1 棋盘数据结构与棋子表示
绘制只是表面,象棋程序的灵魂是规则。我用一个 10 行 9 列的二维数组表示棋盘状态,每个元素存一个枚举值,0 表示空位。红黑双方用正负号区分,红为正,黑为负。
enum PieceType { EMPTY = 0, RED_SHUAI = 1, RED_SHI = 2, RED_XIANG = 3, RED_MA = 4, RED_JU = 5, RED_PAO = 6, RED_BING = 7, BLACK_JIANG = -1, BLACK_SHI = -2, BLACK_XIANG = -3, BLACK_MA = -4, BLACK_JU = -5, BLACK_PAO = -6, BLACK_ZU = -7 }; int board[BOARD_ROWS][BOARD_COLS]; // 初始化,数组下标和字符对应 void InitBoard() { memset(board, 0, sizeof(board)); // 黑方(上方,row 0-4) board[0][0] = BLACK_JU; board[0][1] = BLACK_MA; board[0][2] = BLACK_XIANG; board[0][3] = BLACK_SHI; board[0][4] = BLACK_JIANG; board[0][5] = BLACK_SHI; board[0][6] = BLACK_XIANG; board[0][7] = BLACK_MA; board[0][8] = BLACK_JU; board[2][1] = BLACK_PAO; board[2][7] = BLACK_PAO; for (int c = 0; c < 9; c += 2) board[3][c] = BLACK_ZU; // 红方(下方,row 5-9) board[9][0] = RED_JU; board[9][1] = RED_MA; board[9][2] = RED_XIANG; board[9][3] = RED_SHI; board[9][4] = RED_SHUAI; board[9][5] = RED_SHI; board[9][6] = RED_XIANG; board[9][7] = RED_MA; board[9][8] = RED_JU; board[7][1] = RED_PAO; board[7][7] = RED_PAO; for (int c = 0; c < 9; c += 2) board[6][c] = RED_BING; }用正负号区分阵营有个好处:判断“是否是己方棋子”直接看符号是否相同,判断“是否吃子”看目标符号异号且不为 0。初始布局是标准象棋开局,黑方在 row 0-4,红方在 row 5-9,河界在 row 4 和 row 5 之间。如果你要支持残局,可以随时修改board数组,后面 AI 和绘制都会跟着变。
4.2 各棋子的走法生成
走法合法性是整个程序最容易写错的地方,尤其是马腿、象眼、炮架。我写了一个统一的IsMoveLegal函数,传入起点、终点和当前棋盘,返回是否合法。这里只列车、马、炮三个最复杂的。
int IsMoveLegal(int fromRow, int fromCol, int toRow, int toCol, const int b[BOARD_ROWS][BOARD_COLS]) { int piece = b[fromRow][fromCol]; if (piece == EMPTY) return 0; // 不能吃自己 if (b[toRow][toCol] != 0 && b[toRow][toCol] * piece > 0) return 0; int dRow = toRow - fromRow; int dCol = toCol - fromCol; int steps = 0; switch (abs(piece)) { case 5: // 车:直线,路径中间不得有子 if (dRow != 0 && dCol != 0) return 0; steps = abs(dRow) + abs(dCol); if (steps == 0) return 0; // 检查路径上有无阻挡 for (int s = 1; s < steps; s++) { int r = fromRow + (dRow > 0 ? s : dRow < 0 ? -s : 0); int c = fromCol + (dCol > 0 ? s : dCol < 0 ? -s : 0); if (b[r][c] != 0) return 0; } return 1; case 4: // 马:日字,注意马腿 if (abs(dRow) + abs(dCol) != 3 || abs(dRow) == 0 || abs(dCol) == 0) return 0; // 马腿位置:横走时腿在左右,竖走时腿在上下 int legRow = (abs(dRow) == 2) ? fromRow + dRow/2 : fromRow; int legCol = (abs(dCol) == 2) ? fromCol + dCol/2 : fromCol; if (b[legRow][legCol] != 0) return 0; return 1; case 6: // 炮:走直线,吃子需隔一个炮架 if (dRow != 0 && dCol != 0) return 0; steps = abs(dRow) + abs(dCol); if (steps == 0) return 0; int between = 0; for (int s = 1; s < steps; s++) { int r = fromRow + (dRow > 0 ? s : dRow < 0 ? -s : 0); int c = fromCol + (dCol > 0 ? s : dCol < 0 ? -s : 0); if (b[r][c] != 0) between++; } if (b[toRow][toCol] != 0) // 吃子:必须恰有一个炮架 return between == 1; else // 走子:路径必须空 return between == 0; // 兵、象、士、将略,逻辑类似,判断是否过河/是否越界即可 } return 0; }这段代码可以说是整篇的“玄学”集中地。车的路径检查用一个for循环从 1 到 steps-1,逐格看是否有棋子。马的走法用abs(dRow) + abs(dCol) == 3来判断是不是日字,但还必须排除横竖都为 1 的情况(那是一个小口字,不是日字),所以加abs(dRow)==0 || abs(dCol)==0保证一维是 2、另一维是 1。炮的between计数器在走空位时必须是 0,吃子时必须是 1,这个逻辑很容易少写一种情况。
如果你要支持“将帅对脸”这类特殊规则,需要在走子后检查双方将帅是否在同一列且中间无遮挡,并判定这类走法非法。我一般在IsMoveLegal里再套一层模拟走子检查,把转换后的棋盘传入IsKingInCheck判断。
4.3 将军与胜负检测
胜负检测的核心是:走完一步后,如果己方将帅被攻击,这步就不合法(因为不能送将)。所以需要一个“模拟走子”函数,把临时棋盘复制一份,做落子,然后判断将帅是否受攻。
int IsKingInCheck(const int b[BOARD_ROWS][BOARD_COLS], int isRedTurn) { // 找到当前方的将帅位置 int kingRow = -1, kingCol = -1; int king = isRedTurn ? RED_SHUAI : BLACK_JIANG; for (int r = 0; r < BOARD_ROWS; r++) for (int c = 0; c < BOARD_COLS; c++) if (b[r][c] == king) { kingRow = r; kingCol = c; break; } // 检查是否被对方任何棋子攻击 for (int r = 0; r < BOARD_ROWS; r++) for (int c = 0; c < BOARD_COLS; c++) { int p = b[r][c]; if (p == 0 || p * king > 0) continue; // 空或己方跳过 // 把棋子 p 看作从 (r,c) 走到 (kingRow,kingCol),用 IsMoveLegal 的逆过程 // 注意:IsMoveLegal 假定被移动的是 r,c 上的棋子,这里需要调用一个通用的攻击检测 if (CanAttack(b, r, c, kingRow, kingCol)) return 1; } return 0; }这里我写了一个CanAttack函数,它等价于把目标当作敌方棋子执行走法,但不需要检查“不能送将”,因为目标已经被吃。这个检测在每一步落下后立即执行,如果有将军,就得给出“被将军”的提示。胜负判定就是在轮到一方走棋时,遍历该方所有合法走法,如果找不到任何一步能解除将军,则对方胜。
判断合法走法时,我习惯用“先生成所有起点终点的笛卡尔积”再逐个IsMoveLegal,而不是像 Alpha 引擎那样用位棋盘生成器。因为 9×10 的棋盘总共 90 个交叉点,穷举 90×90 也就 8100 种组合,再做一次模拟检查开销极小,程序完全不会卡顿。这让我省去了大量走法生成代码,也减少了踩坑面。
5. Win32 象棋开发里的 5 个常见问题:现象、原因、解决
5.1 窗口最小化恢复后棋盘变花或出现白色残影
现象:把窗口最小化到任务栏,再点开,棋盘区域有大片白色碎块,好像双缓冲失效了。
原因:很多人只在WM_PAINT里做绘图,但最小化恢复时系统会先发送WM_ERASEBKGND擦除背景,如果你在擦除消息里直接返回 0 而不处理,窗口背景被刷成默认色,然后重绘不及时中间帧就被显示出来。我早期的代码没有处理WM_ERASEBKGND,导致每次恢复都闪白。
解决:在窗口过程中拦截WM_ERASEBKGND,返回 1,禁止系统擦除。同时把棋盘绘制放在一个独立的PaintChessboard函数里,WM_PAINT和任何需要刷新的地方都调用它。
case WM_ERASEBKGND: return 1; // 告诉系统背景已由我们处理,避免白闪这样之后窗口恢复时,只有WM_PAINT触发重绘,而且双缓冲会把棋盘整幅覆盖,残影就没了。
5.2 GetDC 和 ReleaseDC 不配对导致 GDI 泄漏
现象:程序运行一小时,任务管理器看到 GDI 对象数量持续上涨,最终绘制异常,甚至导致系统崩溃。
原因:在WM_PAINT之外的其它地方,比如鼠标响应里,我直接GetDC(hwnd)然后画了一个高亮圈,却忘了ReleaseDC。Win32 的 DC 也是系统资源,不像内存泄漏那样马上报错,但 GDI 句柄有限,泄漏到 10000 个左右就无法创建画笔了。
解决:凡是GetDC拿到的 DC,必须配对ReleaseDC;凡是BeginPaint拿到的 DC,必须配对EndPaint。另外,CreatePen、CreateBrush、CreateFont创建的对象用完必须DeleteObject,否则也是泄漏。我后来写了个ScopedDCRAII 类,把 DC 和 GDI 对象统一放析构里释放,才根治了这个血泪问题。
5.3 中文棋子变成乱码或方框
现象:窗口标题正常,但棋盘上的“车”“马”“炮”全部变成空心方框。
原因:CreateFont的时候没有指定字符集,或者指定的字体名带有中文字符但字符集设成了DEFAULT_CHARSET。Windows 在默认字符集下可能选择西文字体渲染汉字,结果就是乱码。
解决:创建字体时显式指定GB2312_CHARSET,字体名用L"SimSun"或L"Microsoft YaHei"。另外,DrawText时确保传入的是宽字符wchar_t,不要用裸char。
HFONT font = CreateFontW(24, 0, 0, 0, FW_BOLD, 0, 0, 0, GB2312_CHARSET, 0, 0, 0, 0, L"SimSun");如果你在 Visual Studio 里开了 Unicode 编译,CreateFont会自动映射到CreateFontW,但字符串字面量必须写L"..."。复选框漏掉就把这个字体创建放到WM_CREATE里只建一次,这样既避免乱码又省资源。
5.4 鼠标点棋盘边缘时选中错位
现象:点击第一列棋子区域,经常选到第二列;点击棋盘边界线,明明在格子内却没有任何反应。
原因:坐标换算函数里用的CELL_SIZE/2是整数除法,当CELL_SIZE是奇数时会有 0.5 像素误差,而且xToCol对边界情况没有做容错。我最初用int计算,鼠标在边界上抖动一下选中跳跃。
解决:把换算改成先减MARGIN,判断是否小于-CELL_SIZE/2或大于(COLS-1)*CELL_SIZE+CELL_SIZE/2,然后返回 -1;否则用(x - MARGIN + CELL_SIZE/2) / CELL_SIZE。关键是MARGIN必须是一个比棋盘内容稍大的值,不然边框点击会被误判为列 0。
int xToCol(int x) { int rel = x - MARGIN; if (rel < -CELL_SIZE/2 || rel > (BOARD_COLS-1)*CELL_SIZE + CELL_SIZE/2) return -1; int c = (rel + CELL_SIZE/2) / CELL_SIZE; return (c >= 0 && c < BOARD_COLS) ? c : -1; }5.5 消息循环里做递归搜索导致窗口无响应
现象:点击“电脑走一步”后,整个窗口卡死,标题栏出现“未响应”,要等好几秒才恢复。
原因:我在WM_LBUTTONUP里直接调用了三层极大极小搜索,整个搜索要遍历成千上万种走法,每次都模拟棋盘和检测将军。这个计算量在 2 GHz 的 CPU 上也要 300ms 以上,消息循环被阻塞,系统以为程序死了。
解决:一种做法是把深度限制在 2 层,搜索总量控制在 1 万次以内,这样响应时间在几十毫秒。另一种是把搜索放到另一个线程,计算完通过PostMessage给窗口发一个自定义消息WM_AI_DONE,在窗口过程里更新棋盘并重绘。我后来选择了后者,因为以后要加深搜索或读残局库,线程才不会卡界面。
case WM_AI_DONE: // wp 携带 AI 走法编码,这里更新棋盘并 InvalidateRect InvalidateRect(hwnd, NULL, FALSE); return 0;线程的写法不复杂:CreateThread起一个工作线程,内部做搜索,结束后PostMessage(hwnd, WM_AI_DONE, move, 0)。注意线程里不能动board和HWND控件,只能读内存棋盘状态,然后发消息让主线程修改,避免竞态条件。
6. 进阶:给象棋程序加上 FEN 局面导入和自检
到了这一步,你的 Win32 象棋已经能落子、判定将军、跑简单 AI。最后一件事我建议你实现一个 FEN 字符串解析器,用它来做自动化验证和残局测试。
FEN 是中国象棋的标准局面描述格式,一行文本就能完整表示棋盘上的棋子布局、当前轮次和将军状态。比如初始局面的 FEN 是rnbakabnr/9/1c5c1/8/8/8/8/1C5C1/9/RNBAKABNR w - - 0 1。解析它只需要按/分割每一行,数字表示空位数,字母表示棋子。我写了个简版:
void LoadFEN(const char* fen) { // 示例:只解析棋盘部分,轮次部分可忽略 int row = 0, col = 0; for (const char* p = fen; *p && row < BOARD_ROWS; p++) { if (*p == '/') { row++; col = 0; } else if (*p >= '1' && *p <= '9') { col += *p - '0'; } else { // 大写字母红方,小写字母黑方 board[row][col] = CharToPiece(*p); col++; } } }有了 FEN,你可以从网上下载各种残局题目文件,粘贴到程序里做测试。这比手摆棋子快得多,也方便后续写单元测试。我通常准备二十个残局 FEN,启动时自动加载并断言某一步棋是杀招,任何一次走法规则改坏都能立刻暴雷。
验证 AI 效果也有个实用技巧:让程序自我对弈,把每一帧的棋盘状态输出到调试窗口,跑一百局后统计双方平均步数。如果红方和黑方胜率差距超过 30%,说明评估函数里棋子价值或位置分有明显偏差。我评估函数用最朴素的“棋子价值 + 位置价值表”,车 900、马 400、炮 450、兵 20 递增到 200,帅 10000,再把兵过河的位置加分。这个配置能保证不出现明显的战术失误。
最后我养成了一个习惯:每次改动走法规则,先跑一遍 FEN 残局集合,再看一遍自我对弈的一百局统计数据。如果两者都通过,我才会拿鼠标去实际点几盘。这个习惯让我少踩了很多“规则微调导致 AI 下出昏招”的坑。Win32 象棋项目的好处是,它既足够小让你看清每个细节,又足够完整让你体会到桌面程序的完整开发流程——从窗口、绘制、交互到算法全是一条链路。这套经验你下回写任何 Windows 原生工具都能直接复用。希望帮到你。
本文还有配套的精品资源,点击获取