
简介基于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 LChineseChessWindow; 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更大的空间因为棋子是圆形汉字顶部文字出界不好看我实际用MARGIN44第一行棋子和窗口边缘留 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, x6, y); MoveToEx(hdc, x, y-6, NULL); LineTo(hdc, x, y6); } 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_WINDOW1)); // 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, x18, y18); // 写字 HFONT font CreateFont(24, 0, 0, 0, FW_BOLD, 0, 0, 0, GB2312_CHARSET, 0, 0, 0, 0, LSimSun); 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, x16, y16 }; 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字体名用LSimSun或LMicrosoft YaHei。另外DrawText时确保传入的是宽字符wchar_t不要用裸char。HFONT font CreateFontW(24, 0, 0, 0, FW_BOLD, 0, 0, 0, GB2312_CHARSET, 0, 0, 0, 0, LSimSun);如果你在 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_SIZECELL_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 原生工具都能直接复用。希望帮到你。本文还有配套的精品资源点击获取