☰
Windows SDK 中国象棋开发:Win32 消息循环与棋盘坐标映射实战
2026/9/26 22:45:38 网站建设 项目流程

简介:这是一份面向Windows平台C++开发初学者与棋类程序爱好者的象棋程序源码包,基于Windows SDK构建,帮助读者理解如何用原生API实现一款可运行的象棋游戏。压缩包共38个文件,约24KB,以18个ico图标、3个cur光标资源配合1个rc资源脚本构成界面素材,6个头文件与4个cpp源文件承载棋盘绘制、走子逻辑与AI思考等核心代码,另有dsw、dsp工程文件及htm、txt说明文档,便于直接导入旧版Visual Studio编译运行。内容围绕GDI绘图、Windows消息处理、棋盘数据结构、合法走子与将军判定、多线程与Minimax搜索等知识点展开,读者可借此研究窗口创建、鼠标拖放棋子、AI对手实现与调试发布的完整链路。目前已有123人学习,适合作为课程设计或自学Windows SDK图形编程的参考案例。

1. Windows SDK 下做中国象棋:从窗口消息到棋盘坐标的一整条链路

很多人第一次听到「Windows SDK 象棋」,脑子里浮现的是 MFC 拖控件或者某个现成的棋类框架。真上手才发现,Windows SDK 这套东西本质上是 Win32 API 的集合——窗口、消息循环、GDI 绘图、计时器、资源脚本,一个都不少。你要做的不是「调用一个象棋库」,而是从WinMain开始,自己把棋盘画出来、把鼠标点击翻译成格子坐标、把走法规则写成代码。这条路走通了,你会对 Windows 程序的消息驱动模型有完全不一样的理解,而不是停留在「拖个按钮写个事件」的层面。

这篇文章面向两类人:一类是学过 C/C++、想找一个足够复杂又不至于失控的 Win32 练手项目;另一类是做惯了上层框架,想回头补一补窗口过程、设备上下文、坐标变换这些底层概念的工程师。中国象棋恰好是个好载体——规则明确、状态可枚举、界面不花哨,但涉及的消息类型和绘图逻辑足够让你把 Win32 的核心机制走一遍。下面从工程搭建讲到走法生成,再到调试期最容易翻车的地方,每一步都给到能直接编译运行的代码和参数说明。

2. 用 Win32 消息循环搭出棋盘窗口:从 WinMain 到 WM_PAINT

2.1 为什么不用 MFC 或 Qt,而是裸 Win32

选型这件事先说清楚。MFC 封装了窗口类注册和消息映射,写起来快,但你会被DECLARE_MESSAGE_MAP那一套宏绑住,调试时消息流向不直观。Qt 跨平台、信号槽优雅,但引入一个几百 MB 的运行时,对一个象棋程序来说太重。裸 Win32 的好处是:编译出来就是一个几十 KB 的 exe,不依赖任何第三方 DLL,消息从GetMessage到WndProc的路径完全透明。你想在WM_LBUTTONDOWN里打个断点看坐标,没有任何中间层挡着。

代价是你要自己处理窗口类注册、消息循环、GDI 对象的创建和销毁。这些代码量不小,但都是模板化的,写一次就记住了。我一般会先把窗口骨架跑起来,确认能显示一个空白窗口、能响应关闭,再往里填棋盘绘制和交互逻辑。这样出问题时排查范围小。

2.2 窗口骨架:注册类、创建窗口、消息循环

下面是最小可运行的 Win32 窗口代码。编译环境用 Visual Studio 的「空项目」模板,字符集选 Unicode,链接器不需要额外加库,user32.lib和gdi32.lib默认就链上了。

#include <windows.h> LRESULT CALLBACK WndProc(HWND, UINT, WPARAM, LPARAM); int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE hPrev, PWSTR pCmdLine, int nCmdShow) { const wchar_t CLASS_NAME[] = L"ChessWndClass"; WNDCLASS wc = {0}; wc.lpfnWndProc = WndProc; // 窗口过程,所有消息的入口 wc.hInstance = hInstance; wc.lpszClassName = CLASS_NAME; wc.hCursor = LoadCursor(NULL, IDC_ARROW); wc.hbrBackground = (HBRUSH)(COLOR_WINDOW + 1); RegisterClass(&wc); // 客户区按棋盘 9 列 x 10 行、每格 60 像素估算 HWND hwnd = CreateWindowEx( 0, CLASS_NAME, L"Windows SDK 象棋", WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 620, 720, // 窗口宽高,留出边框和标题栏 NULL, NULL, hInstance, NULL); ShowWindow(hwnd, nCmdShow); MSG msg = {0}; while (GetMessage(&msg, NULL, 0, 0) > 0) { TranslateMessage(&msg); DispatchMessage(&msg); } return 0; }

WNDCLASS里hbrBackground用COLOR_WINDOW + 1是系统预定义画刷,省得自己创建再销毁。CreateWindowEx的宽高我按棋盘 9 列 10 行、每格 60 像素来估:棋盘本身 540x600,加上边距和窗口边框,620x720 差不多。这个数值后面画棋盘时还要微调,先跑起来看效果。

消息循环里GetMessage返回值大于 0 才继续,等于 0 是WM_QUIT,小于 0 是出错。很多人写成while (GetMessage(...)),遇到 -1 会死循环,这是个经典坑。

2.3 在 WM_PAINT 里画棋盘:GDI 线条与坐标映射

窗口过程里最核心的是WM_PAINT。棋盘用 GDI 画:横线 10 条、竖线 9 条,中间楚河汉界留空,九宫格画斜线。关键是建立「棋盘坐标 → 屏幕像素」的映射函数,后面鼠标点击反算也要用同一套。

#define CELL 60 // 每格像素 #define MARGIN 40 // 棋盘左上角到客户区边缘的边距 // 棋盘列 0..8,行 0..9,返回屏幕像素坐标 POINT BoardToPixel(int col, int row) { POINT p; p.x = MARGIN + col * CELL; p.y = MARGIN + row * CELL; return p; } void DrawBoard(HDC hdc) { HPEN hPen = CreatePen(PS_SOLID, 2, RGB(60, 60, 60)); HPEN hOld = (HPEN)SelectObject(hdc, hPen); // 10 条横线 for (int r = 0; r < 10; r++) { POINT a = BoardToPixel(0, r); POINT b = BoardToPixel(8, r); MoveToEx(hdc, a.x, a.y, NULL); LineTo(hdc, b.x, b.y); } // 9 条竖线,楚河汉界处(第 4 行到第 5 行之间)断开 for (int c = 0; c < 9; c++) { POINT top = BoardToPixel(c, 0); POINT mid1 = BoardToPixel(c, 4); POINT mid2 = BoardToPixel(c, 5); POINT bot = BoardToPixel(c, 9); MoveToEx(hdc, top.x, top.y, NULL); LineTo(hdc, mid1.x, mid1.y); MoveToEx(hdc, mid2.x, mid2.y, NULL); LineTo(hdc, bot.x, bot.y); } // 九宫斜线:上方 (3,0)-(5,2) 和 (5,0)-(3,2) POINT a1 = BoardToPixel(3,0), a2 = BoardToPixel(5,2); MoveToEx(hdc, a1.x, a1.y, NULL); LineTo(hdc, a2.x, a2.y); POINT b1 = BoardToPixel(5,0), b2 = BoardToPixel(3,2); MoveToEx(hdc, b1.x, b1.y, NULL); LineTo(hdc, b2.x, b2.y); // 下方九宫同理 POINT c1 = BoardToPixel(3,7), c2 = BoardToPixel(5,9); MoveToEx(hdc, c1.x, c1.y, NULL); LineTo(hdc, c2.x, c2.y); POINT d1 = BoardToPixel(5,7), d2 = BoardToPixel(3,9); MoveToEx(hdc, d1.x, d1.y, NULL); LineTo(hdc, d2.x, d2.y); SelectObject(hdc, hOld); DeleteObject(hPen); }

CreatePen创建的画笔必须SelectObject换回旧的再DeleteObject,否则 GDI 对象泄漏,画多了程序会卡。BoardToPixel这个映射函数是整篇文章的枢纽——画棋子、判断点击、画选中高亮,全靠它。CELL和MARGIN两个宏改一个值,整个棋盘布局跟着变,不要在多处硬编码像素。

WM_PAINT里记得先BeginPaint、最后EndPaint,所有绘制在两者之间。窗口被遮挡再恢复时会重新触发WM_PAINT,所以绘制逻辑必须是幂等的——每次从头画整个棋盘,不要试图只画「变化的部分」,那是优化阶段的事。

3. 棋子表示与走法生成:数组、枚举和合法性校验

3.1 用一维数组还是二维数组存棋盘

棋盘状态我一般用int board[10][9],行 0 到 9 从上到下,列 0 到 8 从左到右。红方在下(行 7-9),黑方在上(行 0-2)。每个格子存一个整数:0 表示空,正数表示红方棋子,负数表示黑方。棋子类型用枚举定义:

enum PieceType { EMPTY = 0, KING = 1, // 将/帅 ADVISOR = 2, // 士/仕 ELEPHANT= 3, // 象/相 HORSE = 4, // 马 ROOK = 5, // 车 CANNON = 6, // 炮 PAWN = 7 // 兵/卒 }; int board[10][9]; // 正数红方,负数黑方,绝对值对应 PieceType

用二维数组而不是一维,是因为走法生成里大量出现board[row][col]这种访问,二维写起来直观,编译器优化后和一维寻址没差别。有人喜欢用struct Piece { int type; int side; },可读性好一点,但判断「这个格子是不是对方棋子」时要多写几行。整数编码配合abs()和符号判断,代码更紧凑。

初始化时把红黑双方的棋子摆到标准开局位置。这里容易出错的是马和象的位置——马在车旁边,象在马上方一格。写完后对着真实棋盘数一遍行列,别凭记忆。

3.2 车马炮的走法生成:从伪合法到合法

走法生成分两步:先按棋子类型生成「伪合法走法」(不考虑走后己方是否被将军),再用「走后模拟」过滤掉会导致己方被将军的走法。第一步是重点,每种棋子的规则不同。

车的走法最简单:四个方向直线延伸,遇到己方棋子停,遇到对方棋子可以吃但不能再走。

// 生成车的走法,fromR/fromC 是起点,side 是 1(红) 或 -1(黑) void GenRook(int fromR, int fromC, int side, Move* moves, int* count) { int dirs[4][2] = {{0,1},{0,-1},{1,0},{-1,0}}; for (int d = 0; d < 4; d++) { int r = fromR + dirs[d][0]; int c = fromC + dirs[d][1]; while (r >= 0 && r < 10 && c >= 0 && c < 9) { int target = board[r][c]; if (target == 0) { // 空格,可以走 moves[(*count)++] = (Move){fromR, fromC, r, c}; } else { // 有棋子:对方可以吃,己方不能 if ((target > 0 ? 1 : -1) != side) { moves[(*count)++] = (Move){fromR, fromC, r, c}; } break; // 无论敌友,都不能穿过 } r += dirs[d][0]; c += dirs[d][1]; } } }

炮的走法要单独处理:直线移动时和车一样,但吃子必须隔一个棋子(炮架)。实现时用一个jumped标志,遇到第一个棋子时置位,再遇到棋子时如果是对方且jumped为真才能吃。

马的走法要检查「蹩马腿」:马走日字,但如果马腿位置(相邻的直线格)有棋子,这个方向不能走。八个方向逐个判断,每个方向先检查马腿再检查目标格。

象的走法要检查「塞象眼」且不能过河。士只能在九宫内斜走。将只能在九宫内直走。兵过河前只能前进,过河后可以左右。

这些规则写完后,用一个测试函数把每种棋子的走法打印出来,对着标准开局手动核对。我见过有人把马的蹩腿方向搞反,结果马能穿过棋子,这种 bug 不测很难发现。

3.3 走后模拟与将军检测

伪合法走法生成后,对每一步执行「模拟走子 → 检查己方将是否被攻击 → 撤销走子」。检查将军的逻辑是:找到己方将的位置,然后看对方所有棋子能否攻击到它。为了效率,不用生成对方全部走法,而是反向检查——从将的位置出发,看四个方向有没有对方的车或炮,看马的位置有没有对方的马,等等。

int IsKingInCheck(int side) { // 找到己方将的位置 int kr = -1, kc = -1; for (int r = 0; r < 10 && kr < 0; r++) for (int c = 0; c < 9; c++) if (board[r][c] == side * KING) { kr = r; kc = c; break; } // 检查四个直线方向:车和炮 int dirs[4][2] = {{0,1},{0,-1},{1,0},{-1,0}}; for (int d = 0; d < 4; d++) { int r = kr + dirs[d][0], c = kc + dirs[d][1]; int blockers = 0; while (r >= 0 && r < 10 && c >= 0 && c < 9) { int p = board[r][c]; if (p != 0) { int enemy = (p > 0 ? 1 : -1) != side; if (enemy && abs(p) == ROOK && blockers == 0) return 1; if (enemy && abs(p) == KING && blockers == 0) return 1; // 将帅照面 if (enemy && abs(p) == CANNON && blockers == 1) return 1; blockers++; } r += dirs[d][0]; c += dirs[d][1]; } } // 马和兵的检查略,原理相同:从将的位置反推对方棋子可能的位置 return 0; }

将帅照面这条规则容易被忽略——两个将之间没有棋子时,走成照面是违规的。上面代码里abs(p) == KING && blockers == 0就是处理这个。炮的检查用blockers == 1,因为炮吃子必须隔一个。

模拟走子时直接改board数组,检查完再改回来。注意保存被吃掉的棋子,撤销时恢复。这个「改-查-撤」的模式在搜索算法里会反复出现,写成一个函数复用。

4. 鼠标交互与选中高亮:把点击翻译成棋盘坐标

4.1 反算点击落在哪个格子

WM_LBUTTONDOWN的lParam里低 16 位是 x 坐标,高 16 位是 y 坐标。用GET_X_LPARAM和GET_Y_LPARAM宏取出来(需要#include <windowsx.h>)。然后反算格子:

case WM_LBUTTONDOWN: { int mx = GET_X_LPARAM(lParam); int my = GET_Y_LPARAM(lParam); // 反算:先减边距,再除以格宽,四舍五入 int col = (mx - MARGIN + CELL/2) / CELL; int row = (my - MARGIN + CELL/2) / CELL; if (col >= 0 && col < 9 && row >= 0 && row < 10) { HandleClick(row, col); } return 0; }

+ CELL/2是为了让点击落在格子中心附近就算命中,而不是必须精确点在交叉线上。这个容差对用户体验影响很大——没有它,用户点偏几个像素就没反应,用起来很烦躁。

4.2 选中状态与高亮绘制

维护两个状态变量:selectedRow、selectedCol,-1 表示没有选中。点击逻辑分三种情况:没选中时点己方棋子就选中;已选中时点己方另一个棋子就切换选中;已选中时点合法目标格就走子。

高亮绘制在WM_PAINT里做:如果selectedRow >= 0,在那个格子画一个红色方框或者半透明圆。GDI 没有原生半透明,简单做法是画一个空心矩形:

if (selectedRow >= 0) { POINT p = BoardToPixel(selectedCol, selectedRow); HPEN hSel = CreatePen(PS_SOLID, 3, RGB(220, 40, 40)); HPEN hOld = (HPEN)SelectObject(hdc, hSel); HBRUSH hOldBr = (HBRUSH)SelectObject(hdc, GetStockObject(NULL_BRUSH)); Rectangle(hdc, p.x - CELL/2 + 4, p.y - CELL/2 + 4, p.x + CELL/2 - 4, p.y + CELL/2 - 4); SelectObject(hdc, hOld); SelectObject(hdc, hOldBr); DeleteObject(hSel); }

GetStockObject(NULL_BRUSH)让矩形内部透明,只画边框。每次状态变化后调InvalidateRect(hwnd, NULL, TRUE)触发重绘。TRUE表示擦除背景,避免残影。

4.3 走子、吃子与回合切换

HandleClick里判断目标格是否有对方棋子,有就执行吃子(直接覆盖数组值),然后切换回合。走完后检查对方是否被将死或困毙——这需要生成对方所有合法走法,如果为空且被将军就是将死,为空但没被将军就是困毙(中国象棋里困毙也算输)。

回合切换用一个currentSide变量,1 表示红方走,-1 表示黑方走。每次走子后取反。界面上可以在窗口标题或者状态栏显示「红方走棋」/「黑方走棋」。

5. 避坑与排查:Win32 象棋开发中最容易翻车的五个地方

5.1 棋子画出来是黑块,文字不显示

现象:用TextOut画棋子汉字,结果只看到黑色方块或者什么都不显示。原因通常是字体没设置,或者TextOut用的坐标是文字左上角而不是中心。解决:创建字体CreateFont指定高度和字体名(如「微软雅黑」),SelectObject选入 DC,然后用GetTextExtentPoint32测量文字宽高,把绘制坐标减去一半宽高让文字居中。别忘了用完DeleteObject删字体。

5.2 点击坐标偏移,点到的格子和预期差一格

现象:点击棋盘上的棋子,选中的却是旁边一格。原因多半是MARGIN和CELL在绘制和反算两处不一致,或者窗口有边框导致客户区坐标和屏幕坐标混淆。解决:统一用客户区坐标,WM_LBUTTONDOWN的lParam本来就是客户区坐标,不要再调ScreenToClient。把BoardToPixel和反算逻辑放在一起对照检查,确保用的是同一组宏。

5.3 走子后界面不刷新,棋子留在原地

现象:数组里的数据已经改了,但屏幕上还是旧画面。原因是改完数据没有调InvalidateRect,或者调了但WM_PAINT里没有重画棋子。解决:任何修改棋盘状态的操作后面都跟一句InvalidateRect(hwnd, NULL, FALSE),FALSE表示不擦背景(棋盘绘制会覆盖,擦背景反而闪烁)。如果还有残影,改成TRUE试试。

5.4 程序运行一段时间后卡顿或崩溃

现象:走几十步后界面响应变慢,或者直接崩掉。原因大概率是 GDI 对象泄漏——CreatePen、CreateFont、CreateBrush创建的每个对象都必须DeleteObject。在WM_PAINT里频繁创建销毁更要注意。解决:把常用的画笔、字体、画刷在WM_CREATE时创建一次,存到全局或窗口关联的结构体里,WM_DESTROY时统一销毁。WM_PAINT里只SelectObject和绘制。

5.5 将帅照面没判,走法出现非法状态

现象:两个将之间没有棋子,程序却允许这种局面存在。原因是在走法生成或将军检测里漏了「将帅不能照面」这条规则。解决:在IsKingInCheck里加上同列且中间无棋子时对方将的攻击判定(前面代码里abs(p) == KING && blockers == 0那行)。另外在走法过滤阶段,任何导致照面的走法都要被剔除。

6. 让象棋程序真正能下:简单 AI 与性能调优的一个实用技巧

走到这一步,你的程序已经能双人对弈了。但「Windows SDK 象棋」这个方向真正有意思的地方,是加一个能跟你下几手的 AI。最实用的入门方案是极小化极大搜索(Minimax)配合 Alpha-Beta 剪枝,搜索深度 3 到 4 层,评估函数用棋子价值加位置权重。棋子价值可以简单定为:车 900、马 400、炮 450、士 200、象 200、兵 100(过河后 200)、将 10000。位置权重表网上有很多现成的,直接抄一份就行。

性能上最容易见效的优化是「走法排序」:在 Alpha-Beta 搜索前,把吃子的走法排在前面,尤其是吃大子的走法。这样剪枝效率能提升好几倍。我实测过,同样深度 4,不排序要跑十几秒,排序后一两秒就出结果。另一个技巧是用迭代加深——先搜深度 1,再搜深度 2,以此类推,每次用上一轮的最佳走法作为首选,进一步加速剪枝。

搜索跑在主线程里会卡住界面,用户点关闭都没反应。正确做法是开一个工作线程跑搜索,用PostMessage把结果发回主线程。线程同步用CreateEvent或者简单的原子标志位。别在搜索线程里直接调 GDI 函数,GDI 不是线程安全的。

最后说一个我踩过的坑:搜索深度不要一上来就设 6 或 8。中国象棋的分支因子在 40 左右,深度 6 就是 40^6 的量级,没有好的剪枝和置换表根本跑不动。从深度 3 开始,确认评估函数合理、剪枝正确,再逐步加深。我一般会在搜索函数里加一个节点计数,跑完打印出来,看看实际访问了多少节点——如果远超预期,说明剪枝没生效,先查走法生成有没有重复或者遗漏。

希望帮到你。

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

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

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

立即咨询