☰
Windows SDK 从零实现中国象棋:消息循环、GDI绘图与走子逻辑
2026/10/20 15:32:42 网站建设 项目流程

简介:这是一份面向Windows平台C++开发初学者与棋类程序爱好者的象棋程序源码包,围绕Windows SDK展开,帮助读者理解如何用原生API构建一款可运行的象棋游戏。压缩包共38个文件,约24KB,以18个ico图标、3个cur光标资源、6个h头文件、4个cpp源文件为主,另含dsw/dsp工程文件、rc资源脚本与htm/txt说明文档,覆盖界面绘制、消息处理、棋局数据结构与AI搜索等模块。资源中Mantis系列源码将棋盘绘制、走子逻辑与思考算法拆分到独立文件中,便于按模块阅读与二次修改,适合作为课程设计或SDK入门的参考范例。目前已有123人学习下载,可帮助读者掌握GDI绘图、Windows消息机制、Minimax与Alpha-Beta剪枝等核心知识点,并借鉴其工程组织与调试思路。

1. 用 Windows SDK 从零写一个能走子的象棋程序

很多人第一次听到「Windows SDK 象棋」,脑子里浮现的是 MFC 拖控件或者某个开源引擎的二次封装。但真正用纯 Win32 API 从零搭一个能落子、能判断胜负、能悔棋的象棋程序,其实是一条被低估的练手路径。它逼着你把窗口消息循环、GDI 绘图、鼠标坐标映射、棋盘状态机这四件事串成一条线,而不是靠框架帮你兜底。我当初写第一个版本时,棋子画出来了却点不动,排查半天才发现是WM_LBUTTONDOWN里把客户区坐标当成了屏幕坐标。这篇文章就按「窗口骨架 → 棋盘绘制 → 走子逻辑 → 避坑 → 进阶」的顺序,把每一步的命令、参数和翻车点讲清楚,适合会一点 C++、想搞懂 Win32 消息机制的人照着复现。

2. 窗口骨架与消息循环:先让程序跑起来

2.1 注册窗口类时最容易漏掉的三个字段

Win32 程序的起点是WNDCLASSEX结构体。新手常犯的错是只填lpfnWndProc和hInstance,结果窗口能弹出来但行为诡异。下面是我一般会用的最小可用配置:

// 注册窗口类:字段顺序不重要,但漏填会出玄学问题 WNDCLASSEX wc = { 0 }; wc.cbSize = sizeof(WNDCLASSEX); // 必须填,否则 RegisterClassEx 直接失败 wc.style = CS_HREDRAW | CS_VREDRAW; // 窗口尺寸变化时重绘,棋盘不会留残影 wc.lpfnWndProc = WndProc; // 消息处理函数,核心中的核心 wc.hInstance = hInstance; // 当前进程实例句柄 wc.hCursor = LoadCursor(NULL, IDC_ARROW); wc.hbrBackground = (HBRUSH)(COLOR_WINDOW + 1); // 背景刷,不填会花屏 wc.lpszClassName = L"ChessWndClass"; // 类名,CreateWindowEx 要用同一个 RegisterClassEx(&wc);

cbSize漏填是血泪经验,RegisterClassEx会返回 0,但你不检查返回值就完全不知道错在哪。hbrBackground如果设成NULL,窗口背景不会自动擦除,拖动窗口时会看到上一帧的残影。CS_HREDRAW | CS_VREDRAW这两个 style 保证窗口大小改变时整个客户区重绘,棋盘这种规则图形必须加。

2.2 创建窗口与消息循环的完整命令

注册完类之后,CreateWindowEx创建窗口,然后进入消息循环。这里有个细节:窗口尺寸和客户区尺寸不是一回事,标题栏和边框会占掉一部分。

// 创建窗口:注意窗口尺寸要补偿边框,否则客户区不是你要的 640x640 HWND hwnd = CreateWindowEx( 0, L"ChessWndClass", L"象棋 Demo", WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 640 + 16, 640 + 39, // 补偿边框和标题栏,让客户区接近 640x640 NULL, NULL, hInstance, NULL); ShowWindow(hwnd, SW_SHOW); UpdateWindow(hwnd); // 标准消息循环 MSG msg; while (GetMessage(&msg, NULL, 0, 0)) { TranslateMessage(&msg); DispatchMessage(&msg); } return (int)msg.wParam;

640 + 16和640 + 39这两个补偿值不是固定的,跟系统 DPI 和主题有关。更稳的做法是在WM_CREATE里用AdjustWindowRectEx根据WS_OVERLAPPEDWINDOW反算。我一般会写一个辅助函数,传入期望的客户区宽高,返回窗口宽高。这样换台机器不会因为边框厚度不同导致棋盘被裁掉。

2.3 消息处理函数里必须响应的四类消息

WndProc是整个程序的中枢。对于象棋程序,至少要处理WM_PAINT、WM_LBUTTONDOWN、WM_DESTROY和WM_SIZE。WM_PAINT里做所有绘制,WM_LBUTTONDOWN里做坐标映射和走子判断,WM_DESTROY里PostQuitMessage,WM_SIZE里更新棋盘布局参数。

LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { switch (msg) { case WM_PAINT: { PAINTSTRUCT ps; HDC hdc = BeginPaint(hwnd, &ps); DrawBoard(hdc); // 画棋盘线 DrawPieces(hdc); // 画棋子 EndPaint(hwnd, &ps); return 0; } case WM_LBUTTONDOWN: { int x = LOWORD(lp); // 客户区坐标,不是屏幕坐标 int y = HIWORD(lp); OnClick(x, y); // 映射到棋盘格并处理走子 return 0; } case WM_DESTROY: PostQuitMessage(0); return 0; } return DefWindowProc(hwnd, msg, wp, lp); }

LOWORD(lp)和HIWORD(lp)取出来的是客户区坐标,这一点在WM_LBUTTONDOWN里天然成立,不需要ScreenToClient。但如果你在别的地方用GetCursorPos拿坐标,就必须手动转换。我见过有人在WM_LBUTTONDOWN里又调了一次ScreenToClient,结果坐标全偏了,棋子点哪都不对。

3. 棋盘绘制与坐标映射:让点击落在正确的格子上

3.1 用 GDI 画 9x10 棋盘的循环写法

中国象棋棋盘是 9 列 10 行,共 90 个交叉点。棋子放在交叉点上,不是格子里。所以绘制时画的是线,不是方格。

const int COLS = 9, ROWS = 10; const int CELL = 64; // 每格像素 const int MARGIN = 32; // 棋盘边距 void DrawBoard(HDC hdc) { HPEN pen = CreatePen(PS_SOLID, 2, RGB(0, 0, 0)); HGDIOBJ oldPen = SelectObject(hdc, pen); // 画 10 条横线 for (int r = 0; r < ROWS; r++) { int y = MARGIN + r * CELL; MoveToEx(hdc, MARGIN, y, NULL); LineTo(hdc, MARGIN + (COLS - 1) * CELL, y); } // 画 9 条竖线,中间楚河汉界处断开 for (int c = 0; c < COLS; c++) { int x = MARGIN + c * CELL; if (c == 0 || c == COLS - 1) { // 最左最右竖线贯通 MoveToEx(hdc, x, MARGIN, NULL); LineTo(hdc, x, MARGIN + (ROWS - 1) * CELL); } else { // 中间竖线在楚河汉界断开 MoveToEx(hdc, x, MARGIN, NULL); LineTo(hdc, x, MARGIN + 4 * CELL); MoveToEx(hdc, x, MARGIN + 5 * CELL, NULL); LineTo(hdc, x, MARGIN + (ROWS - 1) * CELL); } } SelectObject(hdc, oldPen); DeleteObject(pen); }

CELL和MARGIN决定了棋盘在客户区的位置。如果客户区是 640x640,MARGIN=32、CELL=64时,棋盘宽度是32*2 + 8*64 = 576,高度是32*2 + 9*64 = 640,刚好放下。楚河汉界在第 4 行和第 5 行之间断开,这是中国象棋的标准画法,中间竖线不能贯通。

3.2 鼠标坐标到棋盘行列的映射公式

点击事件拿到的是像素坐标,需要反算成棋盘的行列。公式很简单,但边界处理容易翻车。

// 返回 -1 表示点击不在棋盘交叉点附近 int PixelToCol(int x) { int c = (x - MARGIN + CELL / 2) / CELL; if (c < 0 || c >= COLS) return -1; return c; } int PixelToRow(int y) { int r = (y - MARGIN + CELL / 2) / CELL; if (r < 0 || r >= ROWS) return -1; return r; }

+ CELL / 2是四舍五入,让点击落在交叉点附近半个格子内都能命中。如果不加这个偏移,必须精确点到交叉点像素上,用户体验极差。返回 -1 表示点到了棋盘外,直接忽略。这里有个坑:如果x - MARGIN是负数,整数除法会向零取整,-10 / 64结果是 0 而不是 -1,所以必须先判断范围再算,或者用浮点。我一般会在调用前先检查x >= MARGIN - CELL/2 && x <= MARGIN + (COLS-1)*CELL + CELL/2。

3.3 棋子绘制的两种方案与选型理由

棋子可以用Ellipse画圆加TextOut写字,也可以用位图贴图。纯 SDK 方案我推荐前者,因为不依赖外部资源文件,编译出来一个 exe 就能跑。

void DrawPiece(HDC hdc, int row, int col, const wchar_t* name, COLORREF color) { int x = MARGIN + col * CELL; int y = MARGIN + row * CELL; int r = CELL / 2 - 4; // 棋子半径,留 4 像素间隙 HBRUSH brush = CreateSolidBrush(RGB(255, 245, 220)); HGDIOBJ oldBrush = SelectObject(hdc, brush); HPEN pen = CreatePen(PS_SOLID, 2, color); HGDIOBJ oldPen = SelectObject(hdc, pen); Ellipse(hdc, x - r, y - r, x + r, y + r); SetBkMode(hdc, TRANSPARENT); SetTextColor(hdc, color); SetTextAlign(hdc, TA_CENTER | TA_BASELINE); TextOut(hdc, x, y + 8, name, wcslen(name)); SelectObject(hdc, oldBrush); SelectObject(hdc, oldPen); DeleteObject(brush); DeleteObject(pen); }

SetBkMode(TRANSPARENT)必须加,否则文字背景会盖住棋子圆形。TA_CENTER | TA_BASELINE让文字水平居中、垂直基线对齐,y + 8是微调,让字大致落在圆心。棋子半径CELL/2 - 4留出间隙,避免相邻棋子视觉上粘在一起。红方用深红色,黑方用黑色,这是标准配色。

4. 走子逻辑与状态管理:从点击到胜负判断

4.1 棋盘状态用二维数组还是位棋盘

90 个交叉点,用int board[10][9]完全够用,可读性最好。位棋盘(bitboard)在象棋里收益不大,因为棋盘太小,而且走法规则复杂,位运算反而难调试。我一般用枚举表示棋子:

enum Piece { EMPTY = 0, RED_KING = 1, RED_ADVISOR, RED_ELEPHANT, RED_HORSE, RED_ROOK, RED_CANNON, RED_PAWN, BLACK_KING = 8, BLACK_ADVISOR, BLACK_ELEPHANT, BLACK_HORSE, BLACK_ROOK, BLACK_CANNON, BLACK_PAWN }; int board[ROWS][COLS]; // 全局棋盘状态 int selectedRow = -1, selectedCol = -1; // 当前选中的棋子 bool redTurn = true; // 红方先行

用1-7表示红方,8-14表示黑方,EMPTY=0。判断阵营只需要piece >= 1 && piece <= 7或piece >= 8。这种编码方式在走法生成里很省事,不需要额外存颜色字段。

4.2 点击选子与走子的状态机

点击逻辑分两种情况:没选中任何棋子时,点自己的棋子就选中;已选中棋子时,点空位或对方棋子就尝试走子。

void OnClick(int x, int y) { int col = PixelToCol(x); int row = PixelToRow(y); if (col < 0 || row < 0) return; // 点棋盘外,忽略 int piece = board[row][col]; bool isRed = (piece >= RED_KING && piece <= RED_PAWN); bool isBlack = (piece >= BLACK_KING && piece <= BLACK_PAWN); if (selectedRow < 0) { // 未选中状态:只能选自己阵营的棋子 if ((redTurn && isRed) || (!redTurn && isBlack)) { selectedRow = row; selectedCol = col; InvalidateRect(hwnd, NULL, FALSE); // 触发重绘,高亮选中 } return; } // 已选中状态:尝试走子 if (CanMove(selectedRow, selectedCol, row, col)) { board[row][col] = board[selectedRow][selectedCol]; board[selectedRow][selectedCol] = EMPTY; redTurn = !redTurn; selectedRow = selectedCol = -1; InvalidateRect(hwnd, NULL, FALSE); CheckGameOver(); } else { // 非法走法:取消选中或改选 selectedRow = selectedCol = -1; InvalidateRect(hwnd, NULL, FALSE); } }

InvalidateRect第三个参数FALSE表示不擦除背景,减少闪烁。如果发现重绘有残影,改成TRUE或者用双缓冲。CanMove是走法校验函数,需要按棋子类型分别判断。这里不展开所有棋子的走法,但核心思路是:先判断目标位置是不是己方棋子,再按类型检查路径和步长。

4.3 走法校验里必须处理的三个边界

以车和炮为例,路径上有没有阻挡是必须检查的。车走直线,路径上不能有任何棋子;炮吃子时必须隔一个棋子,移动时不能有阻挡。

bool RookCanMove(int fr, int fc, int tr, int tc) { if (fr != tr && fc != tc) return false; // 必须直线 int dr = (tr > fr) ? 1 : (tr < fr) ? -1 : 0; int dc = (tc > fc) ? 1 : (tc < fc) ? -1 : 0; int r = fr + dr, c = fc + dc; while (r != tr || c != tc) { if (board[r][c] != EMPTY) return false; // 路径有阻挡 r += dr; c += dc; } return true; } bool CannonCanMove(int fr, int fc, int tr, int tc) { if (fr != tr && fc != tc) return false; int dr = (tr > fr) ? 1 : (tr < fr) ? -1 : 0; int dc = (tc > fc) ? 1 : (tc < fc) ? -1 : 0; int r = fr + dr, c = fc + dc; int blockers = 0; while (r != tr || c != tc) { if (board[r][c] != EMPTY) blockers++; r += dr; c += dc; } if (board[tr][tc] == EMPTY) return blockers == 0; // 移动不能有阻挡 return blockers == 1; // 吃子必须隔一个 }

炮的规则是新手最容易写错的:移动时和车一样不能有阻挡,但吃子时必须恰好隔一个棋子。blockers计数在目标位置之前统计,目标位置的棋子不算阻挡。如果目标位置是空的,blockers必须为 0;如果目标位置有棋子(吃子),blockers必须为 1。

4.4 胜负判断与悔棋栈的实现

胜负判断很简单:某一方的将/帅被吃掉就结束。但更严谨的做法是判断「将死」和「困毙」,这需要生成所有合法走法。对于练手项目,先做「吃掉将帅即胜」就够了。

悔棋用栈实现,每次走子前把棋盘状态压栈:

struct MoveRecord { int board[ROWS][COLS]; bool redTurn; }; std::vector<MoveRecord> history; void SaveState() { MoveRecord rec; memcpy(rec.board, board, sizeof(board)); rec.redTurn = redTurn; history.push_back(rec); } void Undo() { if (history.empty()) return; MoveRecord rec = history.back(); history.pop_back(); memcpy(board, rec.board, sizeof(board)); redTurn = rec.redTurn; selectedRow = selectedCol = -1; InvalidateRect(hwnd, NULL, FALSE); }

memcpy整个棋盘是 90 个 int,360 字节,压栈开销可以忽略。history用std::vector自动扩容,不需要手动管理内存。悔棋后要清空选中状态并重绘,否则高亮框会留在旧位置。

5. 避坑与常见问题排查

5.1 窗口重绘时棋子闪烁严重

现象:拖动窗口或频繁走子时,棋盘和棋子明显闪烁。原因是WM_PAINT里先擦背景再画图,两次操作之间屏幕刷新了。解决方法是双缓冲:在内存 DC 里画完整个画面,再一次性BitBlt到屏幕。

case WM_PAINT: { PAINTSTRUCT ps; HDC hdc = BeginPaint(hwnd, &ps); HDC memDC = CreateCompatibleDC(hdc); HBITMAP memBmp = CreateCompatibleBitmap(hdc, 640, 640); HGDIOBJ oldBmp = SelectObject(memDC, memBmp); // 先在内存 DC 里画 FillRect(memDC, &ps.rcPaint, (HBRUSH)(COLOR_WINDOW + 1)); DrawBoard(memDC); DrawPieces(memDC); // 一次性拷贝到屏幕 BitBlt(hdc, 0, 0, 640, 640, memDC, 0, 0, SRCCOPY); SelectObject(memDC, oldBmp); DeleteObject(memBmp); DeleteDC(memDC); EndPaint(hwnd, &ps); return 0; }

CreateCompatibleBitmap必须用屏幕 DC 创建,不能用内存 DC,否则位图是单色的。SelectObject保存旧对象,用完恢复再删除,否则资源泄漏。

5.2 点击坐标偏移,点 A 格却选中 B 格

现象:鼠标点某个交叉点,选中的却是旁边的棋子。原因通常是MARGIN和CELL在绘制和映射时用了不同的值,或者窗口边框补偿没算对。排查方法:在OnClick里用TextOut把row和col打印到窗口标题栏,看实际映射结果。

wchar_t buf[64]; swprintf_s(buf, L"row=%d col=%d", row, col); SetWindowText(hwnd, buf);

如果打印出来的行列和预期差 1,检查PixelToCol里的+ CELL/2是否漏了,或者MARGIN是否和绘制时一致。另一个常见原因是 DPI 缩放:在高分屏上,系统可能对窗口做了缩放,客户区坐标和实际像素不是 1:1。需要在 manifest 里声明 DPI 感知,或者用GetDpiForWindow动态计算。

5.3 棋子画出来了但文字不显示

现象:圆形棋子正常,但上面的「车」「马」等字看不到。原因通常是SetBkMode没设成TRANSPARENT,文字背景色和棋子填充色一样,字被盖住了。或者SetTextColor设成了和背景相同的颜色。检查DrawPiece里SetBkMode和SetTextColor的调用顺序,确保在TextOut之前设置。

另一个可能是字体问题。默认字体可能不支持某些汉字,或者字号太小。我一般会显式创建字体:

HFONT font = CreateFont(28, 0, 0, 0, FW_BOLD, FALSE, FALSE, FALSE, DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, DEFAULT_QUALITY, DEFAULT_PITCH | FF_DONTCARE, L"微软雅黑"); HGDIOBJ oldFont = SelectObject(hdc, font); // ... TextOut ... SelectObject(hdc, oldFont); DeleteObject(font);

DEFAULT_CHARSET保证汉字能正常显示,FW_BOLD加粗让字更清晰。字体用完要恢复并删除。

5.4 走子后棋盘状态错乱,出现两个相同的将

现象:走子后某个棋子消失,或者出现两个红帅。原因通常是board数组越界写入了。检查CanMove里有没有对row、col做范围校验,特别是马走日、象走田这种可能跳出棋盘的走法。马的位置加上偏移后可能超出0-9或0-8,必须先判断再访问board。

bool InBoard(int r, int c) { return r >= 0 && r < ROWS && c >= 0 && c < COLS; }

所有走法生成和校验函数开头都要调InBoard。这个函数看起来简单,但漏掉一次就可能导致数组越界,症状是随机的,非常难查。

5.5 悔棋后选中状态没清空导致高亮框残留

现象:悔棋后棋盘恢复了,但之前选中的棋子还有高亮框。原因是Undo里没有重置selectedRow和selectedCol。解决很简单,在Undo末尾加selectedRow = selectedCol = -1;然后InvalidateRect。高亮框的绘制逻辑是:如果selectedRow >= 0,在对应位置画一个红色矩形。所以只要选中状态清了,重绘后高亮自然消失。

6. 进阶技巧:用双缓冲和走法提示提升体验

双缓冲在上面已经给了代码,这里说一个更实用的技巧:走法提示。选中棋子后,把所有合法落点用半透明圆点标出来,用户不用猜哪些位置能走。实现方式是遍历棋盘 90 个位置,对每个位置调CanMove,能走就画一个小圆。

void DrawHints(HDC hdc) { if (selectedRow < 0) return; HBRUSH hintBrush = CreateSolidBrush(RGB(0, 180, 0)); HGDIOBJ oldBrush = SelectObject(hdc, hintBrush); HGDIOBJ oldPen = SelectObject(hdc, GetStockObject(NULL_PEN)); for (int r = 0; r < ROWS; r++) { for (int c = 0; c < COLS; c++) { if (r == selectedRow && c == selectedCol) continue; if (CanMove(selectedRow, selectedCol, r, c)) { int x = MARGIN + c * CELL; int y = MARGIN + r * CELL; Ellipse(hdc, x - 6, y - 6, x + 6, y + 6); } } } SelectObject(hdc, oldBrush); SelectObject(hdc, oldPen); DeleteObject(hintBrush); }

GetStockObject(NULL_PEN)让圆点没有边框,只填充绿色。CanMove会被调用 90 次,对于象棋这种小棋盘完全无压力。如果以后加 AI 搜索,这个函数会被调用几万次,那时候需要做走法生成缓存,但练手阶段不用考虑。

另一个值得做的进阶是「最后一步高亮」:记录上一步的起点和终点,用不同颜色标出来,方便复盘。实现方式和走法提示类似,只是颜色换成黄色或蓝色。这两个提示加上双缓冲,整个程序的体验就从「能跑」变成「能用」了。

我写这个程序最大的教训是:Win32 的坐标系统和资源管理没有捷径,每一个SelectObject都要配对DeleteObject,每一个BeginPaint都要配对EndPaint。刚开始我觉得这些规则繁琐,直到程序跑久了 GDI 对象泄漏到几千个,窗口直接画不出东西,才老老实实回去补。现在我的习惯是每写一个Create就立刻写对应的Delete,中间再填逻辑。希望帮到你。

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

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

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

立即咨询