
简介这是一份面向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 LChessWndClass; // 类名CreateWindowEx 要用同一个 RegisterClassEx(wc);cbSize漏填是血泪经验RegisterClassEx会返回 0但你不检查返回值就完全不知道错在哪。hbrBackground如果设成NULL窗口背景不会自动擦除拖动窗口时会看到上一帧的残影。CS_HREDRAW | CS_VREDRAW这两个 style 保证窗口大小改变时整个客户区重绘棋盘这种规则图形必须加。2.2 创建窗口与消息循环的完整命令注册完类之后CreateWindowEx创建窗口然后进入消息循环。这里有个细节窗口尺寸和客户区尺寸不是一回事标题栏和边框会占掉一部分。// 创建窗口注意窗口尺寸要补偿边框否则客户区不是你要的 640x640 HWND hwnd CreateWindowEx( 0, LChessWndClass, 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里PostQuitMessageWM_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决定了棋盘在客户区的位置。如果客户区是 640x640MARGIN32、CELL64时棋盘宽度是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表示黑方EMPTY0。判断阵营只需要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::vectorMoveRecord 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 个 int360 字节压栈开销可以忽略。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, Lrow%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中间再填逻辑。希望帮到你。本文还有配套的精品资源点击获取