news 2026/10/6 3:18:32

用Win32 API从零开发中国象棋:窗口、绘制与规则全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用Win32 API从零开发中国象棋:窗口、绘制与规则全解析

简介:基于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 原生工具都能直接复用。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/6 3:17:19

RFID仓库系统落地实战:标签选型、读写器部署与中间件配置

简介&#xff1a;这是一套面向物联网与仓储信息化开发者的基于RFID技术的仓库管理系统实战项目&#xff0c;聚焦于解决传统仓库作业中人工录入效率低、数据滞后、库存失真等痛点&#xff0c;适用于高校课程设计、毕业设计及中小型企业轻量级仓储数字化改造场景。资源包共42个文…

作者头像 李华
网站建设 2026/10/6 3:16:38

DNS切换与测速实战:从公共DNS到一键优化工具

1. 先搞明白&#xff1a;为什么换个DNS就能让网速起飞1.1 你以为的网速慢&#xff0c;可能根本不是带宽的锅先讲一个我自己的真实经历。去年有段时间&#xff0c;家里宽带是200M&#xff0c;测速软件显示下载能跑满180Mbps以上&#xff0c;但打开很多网页就是要转圈五六秒&…

作者头像 李华
网站建设 2026/10/6 3:16:37

ASP+SQL旅游管理系统快速搭建与防坑指南

简介&#xff1a;本资源是一套完整的ASPSQL旅游管理系统毕业设计实战材料&#xff0c;面向计算机专业本科生、Web开发初学者及课程设计实践者&#xff0c;解决旅游业务信息化管理中的用户交互、订单处理与后台运维等核心问题。压缩包共148个文件&#xff0c;27.87MB&#xff0c…

作者头像 李华
网站建设 2026/10/6 3:14:57

多模型融合实战:二手车价格预测系统完整链路

简介&#xff1a;基于机器学习和多模型融合的二手车交易市场大数据挖掘项目&#xff0c;包含完整源码与项目说明&#xff0c;面向计算机、人工智能、大数据等相关专业学生&#xff0c;适用于课程设计、期末大作业或毕业设计场景。项目围绕交易价格预测和成交周期挖掘两大任务&a…

作者头像 李华
网站建设 2026/10/6 3:12:44

LeetCode 21 合并两个有序链表:C语言指针操作与哨兵节点详解

我拿这道题去面过不少应届生&#xff0c;也看大家刷题打卡提到过LeetCode 21。每次看到"合并两个有序链表"被标记成简单题&#xff0c;我都想说&#xff1a;简单是简单&#xff0c;但能把C语言版本一次写对的人&#xff0c;确实不多。本质原因很简单——这题考的不是…

作者头像 李华