
简介这是一份面向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_PAINT2.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[] LChessWndClass; 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, LWindows 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 开始确认评估函数合理、剪枝正确再逐步加深。我一般会在搜索函数里加一个节点计数跑完打印出来看看实际访问了多少节点——如果远超预期说明剪枝没生效先查走法生成有没有重复或者遗漏。希望帮到你。本文还有配套的精品资源点击获取