
简介基于Windows SDK的象棋程序开发资源包面向正在学习Windows API、GDI界面绘制与游戏逻辑设计的开发者。资源以C实现完整演示了从窗口创建、棋盘绘制到鼠标事件处理、棋局数据结构与AI搜索算法的落地过程适合作为课程设计或入门级桌面游戏项目的参考。压缩包共38个文件包含6个头文件、4个C源文件、18个图标及多个光标与资源脚本整体仅24KB内容精炼。已有123人学习下载。配套说明文档与工程文件清晰标注了项目结构可帮助快速定位绘图、逻辑与AI模块其中MantisChessThink等文件展示了Minimax搜索思路适合希望在传统象棋程序中加入人机对弈功能的开发者借鉴。1. windows sdk 象棋不靠引擎Win32 也能把棋盘做活「windows sdk 象棋」这条路说穿了就是不用游戏引擎、不用 Qt直接用 Win32 窗口加 GDI 把棋盘画出来再把走法规则和人机搜索一点一点补上。这样做出来的程序只有几十 KB双击就能跑没有运行时依赖代价是消息循环、绘制刷新、坐标换算甚至「马怎么跳」都得自己写。很多人以为难点在画棋盘真正做完才知道规则判定和搜索才是熬人的部分画错顶多难看规则写错会让你一步棋都走不下去。适合想弄清 Windows 桌面程序底层机制、又想拿象棋当练手项目的开发者如果你要的是跨平台或炫酷特效这套方案并不省力。2. 窗口与棋盘渲染GDI 绘图、双缓冲与坐标映射2.1 为什么先搭窗口而不是先写规则做这个标题下的项目最忌讳一上来就写走法生成。棋盘都没画出来你没法验证规则对不对排错全靠脑补写两天就崩心态。我一般先搭一个能显示棋盘的窗口再逐步加规则和 AI每加一层都能肉眼看到结果出问题也容易定位。选 Win32 加 GDI 的理由有三条。第一标题锁死了方向就是要接触 SDK 底层的窗口过程、消息循环和设备上下文这才是这个练手项目的核心价值第二GDI 画棋盘和棋子足够用透视、粒子这类特效用不上第三打包简单一个 exe 加一套资源就完事不用拖一堆 DLL。代价是响应式布局、字体抗锯齿这些得自己处理但 90 个交叉点的棋盘根本不需要考虑这些。2.2 最小窗口骨架注册类、消息循环和自定义消息先看最简骨架注册窗口类、创建窗口、进入消息循环。这个结构后面所有的绘制、鼠标响应、AI 线程通知都挂在这个骨架上。// Win32 最小窗口骨架注册类、创建窗口、消息循环 #include windows.h const wchar_t* kClassName LChessSdkWnd; LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { switch (msg) { case WM_PAINT: PaintBoard(hwnd); // 绘制统一放这里见 2.3 return 0; case WM_DESTROY: PostQuitMessage(0); return 0; default: return DefWindowProc(hwnd, msg, wp, lp); } } int WINAPI WinMain(HINSTANCE hInst, HINSTANCE, LPSTR, int nShow) { WNDCLASS wc {0}; wc.lpfnWndProc WndProc; wc.hInstance hInst; wc.hCursor LoadCursor(NULL, IDC_ARROW); wc.hbrBackground (HBRUSH)GetStockObject(WHITE_BRUSH); wc.lpszClassName kClassName; RegisterClass(wc); HWND hwnd CreateWindow(kClassName, L象棋Win32 SDK, WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 680, 700, NULL, NULL, hInst, NULL); ShowWindow(hwnd, nShow); MSG msg; while (GetMessage(msg, NULL, 0, 0) 0) { TranslateMessage(msg); DispatchMessage(msg); } return 0; }窗口外部尺寸留 680x700依据是棋盘交叉点 9 列 x 10 行格子边长 60 像素、边距 40 像素客户区宽度等于 8 乘 60 加两倍 40 等于 560高度等于 9 乘 60 加两倍 40 等于 620再加标题栏和边框680x700 够用。注册类时给了白色背景画刷兜底实际绘制全在 WM_PAINT 里做。GetMessage 返回 0 表示收到 WM_QUIT消息循环结束程序退出。2.3 双缓冲绘制把闪烁问题消灭在渲染层直接在 WM_PAINT 里往窗口 DC 上画窗口一刷新就会闪因为擦背景和画新内容之间存在空窗期。经验做法是先画到内存位图再一次性 BitBlt 到屏幕也就是双缓冲。// 双缓冲绘制先画内存 DC再一次上屏 void PaintBoard(HWND hwnd) { PAINTSTRUCT ps; HDC hdc BeginPaint(hwnd, ps); // 创建内存 DC 和兼容位图所有绘制都往这上面画 HDC memDC CreateCompatibleDC(hdc); HBITMAP bmp CreateCompatibleBitmap(hdc, kClientW, kClientH); HGDIOBJ oldBmp SelectObject(memDC, bmp); FillRect(memDC, ps.rcPaint, (HBRUSH)GetStockObject(WHITE_BRUSH)); DrawBoardLines(memDC); // 画 9 条竖线、10 条横线、九宫和河界 DrawPieces(memDC, g_board); // 遍历棋盘数组画棋子 // 内存画完了一次上屏 BitBlt(hdc, 0, 0, kClientW, kClientH, memDC, 0, 0, SRCCOPY); // 恢复旧对象再删除否则 GDI 对象泄漏 SelectObject(memDC, oldBmp); DeleteObject(bmp); DeleteDC(memDC); EndPaint(hwnd, ps); }关键在最后三步先 SelectObject 恢复旧位图再 DeleteObject 删位图最后 DeleteDC 删内存 DC。顺序反了会删不掉位图跑一晚上 GDI 对象暴涨窗口越来越卡。BitBlt 的最后一个参数 SRCCOPY 是直接覆盖不用考虑透明。DrawPieces 里画棋子用 Ellipse 画圆、DrawText 或 TextOut 写字红方用红色画笔黑方用黑色画笔字体可以直接用系统字体。2.4 行列坐标与像素坐标互转棋盘数据用 row 和 col 表示row 0 到 9 从上到下col 0 到 8 从左到右。绘制和鼠标命中的换算公式是同一个反向换算要加半个格子做四舍五入不然点偏一个像素就选到隔壁交叉点。// 行列 - 像素交叉点 9 列 x 10 行 int px kMargin col * kCell; // 第 col 列交叉点的 X 坐标 int py kMargin row * kCell; // 第 row 行交叉点的 Y 坐标 // 像素 - 最近行列 int col (px - kMargin kCell / 2) / kCell; int row (py - kMargin kCell / 2) / kCell;其中 kMargin 取 40kCell 取 60都是像素单位。加 kCell / 2 的意思是鼠标落在格子里偏左上偏右下都算到最近的交叉点这比不加偏移手感好很多。注意这个换算只对红方视角成立黑方视角要把 row 翻转成 9 减 row否则黑方棋子点了没反应这个坑后面避坑章专门说。3. 走法与局面判定数据表示是棋力的一半3.1 棋盘数组与棋子编号走法生成先定数据表示。中国象棋用二维数组最直观调试的时候把棋盘打出来一眼就能看出问题。位棋盘那套对国际象棋合适对中国象棋收益不大还增加理解成本。#define ROW 10 #define COL 9 // 0 表示空红方为正数黑方为负数 // 1 将 2 仕 3 相 4 马 5 车 6 炮 7 兵 int g_board[ROW][COL];红方正数、黑方负数这个约定的好处是判断归属只要看符号不用额外记一套颜色表。初始局面按标准布局填进数组红方在 row 0 到 3黑方在 row 6 到 9。写一个 ResetBoard 函数按行初始化开局时调用一次悔棋功能也能复用这份数据。3.2 单步走法合法性检查这是整个项目最核心的函数走子之前先问它这一手合不合法。我按棋子类型逐个分支车和炮需要扫路径马和相要注意蹩腿和塞象眼兵要看是否过河将和仕要限制在九宫。// 判断 (fr,fc) 到 (tr,tc) 这一步对 side 是否合法 // side: 0红方, 1黑方 int IsLegalMove(int b[ROW][COL], int fr, int fc, int tr, int tc, int side) { int pc b[fr][fc]; // 起点没子或起点的子不是本方棋子 if (pc 0) return 0; if (side 0 pc 0) return 0; if (side 1 pc 0) return 0; // 边界检查防止数组越界 if (tr 0 || tr 9 || tc 0 || tc 8) return 0; int tp b[tr][tc]; // 目标是自己棋子不能吃 if (side 0 tp 0) return 0; if (side 1 tp 0) return 0; int dr abs(tr - fr); int dc abs(tc - fc); int r, c; switch (abs(pc)) { case 5: // 车直线路径上不能有子 if (dr ! 0 dc ! 0) return 0; if (dr 0) { int step (tc fc) ? 1 : -1; for (c fc step; c ! tc; c step) if (b[fr][c] ! 0) return 0; } else { int step (tr fr) ? 1 : -1; for (r fr step; r ! tr; r step) if (b[r][fc] ! 0) return 0; } return 1; case 6: // 炮走直线时不能有子吃子时中间恰好一个炮架 if (dr ! 0 dc ! 0) return 0; int cnt 0; if (dr 0) { int step (tc fc) ? 1 : -1; for (c fc step; c ! tc; c step) if (b[fr][c] ! 0) cnt; } else { int step (tr fr) ? 1 : -1; for (r fr step; r ! tr; r step) if (b[r][fc] ! 0) cnt; } if (tp 0) return cnt 0; // 走子不能隔子 return cnt 1; // 吃子恰好一个炮架 case 4: // 马日字蹩马腿 if (!(dr 2 dc 1) !(dr 1 dc 2)) return 0; if (dr 2 dc 1) { int legR fr (tr - fr) / 2; // 马腿在纵向中间 if (b[legR][fc] ! 0) return 0; } else { int legC fc (tc - fc) / 2; // 马腿在横向中间 if (b[fr][legC] ! 0) return 0; } return 1; case 3: // 相田字塞象眼不能过河 if (dr ! 2 || dc ! 2) return 0; if (side 0 tr 4) return 0; // 红相不能过河 if (side 1 tr 5) return 0; // 黑相不能过河 int legR fr (tr - fr) / 2; int legC fc (tc - fc) / 2; if (b[legR][legC] ! 0) return 0; // 塞象眼 return 1; case 7: // 兵过河前只能前进过河后可横走永不后退 if (dr dc ! 1) return 0; if (side 0) { if (tr fr) return 0; // 红兵只能向下 if (fr 4 tc ! fc) return 0; // 未过河不能横走 } else { if (tr fr) return 0; // 黑兵只能向上 if (fr 5 tc ! fc) return 0; // 未过河不能横走 } return 1; case 2: // 仕斜走一格限九宫 if (dr ! 1 || dc ! 1) return 0; if (tc 3 || tc 5) return 0; if (side 0 (tr 2 || tr 0)) return 0; if (side 1 (tr 7 || tr 9)) return 0; return 1; case 1: // 将九宫内走一格照面规则交给试走判将 if (dr dc ! 1) return 0; if (tc 3 || tc 5) return 0; if (side 0 (tr 2 || tr 0)) return 0; if (side 1 (tr 7 || tr 9)) return 0; return 1; } return 0; }这段代码的坑点都在注释里车扫路径的循环从起点邻居开始到终点前一个停止不能把起点或终点的棋子算进路径炮分走子和吃子两种清况走子要求路径上无子吃子要求恰好一个炮架马的蹩腿位置要看日字的方向竖跳两格马腿在纵向中间横跳两格马腿在横向中间。相和仕的九宫判断要分 side红方在上三行黑方在下三行。3.3 将军判定与「自杀走法」过滤IsLegalMove 只保证这一步符合棋子走法不保证走完这一步老将还在。常见的问题是玩家把将走到对方炮口或者主动送将。处理办法很简单但很可靠试走一步然后判断当前方是否被将军如果被将军这一步就不合法。// 判断 side 方是否被将军找将然后看对方棋子能否吃到将 int IsChecked(int b[ROW][COL], int side) { int kr -1, kc -1; for (int r 0; r ROW; r) for (int c 0; c COL; c) if (b[r][c] (side 0 ? 1 : -1)) { kr r; kc c; } // 遍历对方棋子只要有一个能一步吃到将就是被将军 for (int r 0; r ROW; r) for (int c 0; c COL; c) if (b[r][c] ! 0 ((side 0 b[r][c] 0) || (side 1 b[r][c] 0))) if (IsLegalMove(b, r, c, kr, kc, side 0 ? 1 : 0)) return 1; return 0; } // 试走过滤先摆出走完的局面再判将 int TryMove(int b[ROW][COL], int fr, int fc, int tr, int tc, int side) { int saved b[tr][tc]; int pc b[fr][fc]; b[tr][tc] pc; b[fr][fc] 0; int ok !IsChecked(b, side); // 走完自己不能被将军 b[fr][fc] pc; b[tr][tc] saved; return ok; }这里还有一个容易漏的规则将帅不能照面。两将同列且中间无子谁先走谁就输了。这个规则不用在 IsLegalMove 里单独写因为走到照面位置后IsChecked 会检查到对面将能沿直线吃到自己自然把这步判为不合法。4. 人机对战引擎评估函数与搜索深度的取舍4.1 评估函数子力价值之外给位置加分人机对战的核心是评估加搜索。评估函数回答「这个局面谁好」搜索回答「怎么走到那个局面」。评估函数先算子力子力价值用经验表。棋子基础价值车1000马450炮450兵200过河后 300仕200相200将100000将设 10 万是为了让搜索永远优先保帅不会出现吃将还要比较分值的局面。马炮同分是行内比较常见的开局取值残局里马炮差异明显可以通过位置分调整。位置分我一般加三项兵过河每步加 20马在中间区域加 10车占中列加 10。评估函数写成遍历棋盘累加红方为正黑方为负最后按当前轮到谁乘以正负号。// 简化评估子力加位置分正数代表红方优势 int Evaluate(int b[ROW][COL]) { static int val[8] {0, 100000, 200, 200, 450, 1000, 450, 200}; int score 0; for (int r 0; r ROW; r) for (int c 0; c COL; c) { int pc b[r][c]; if (pc 0) continue; int v val[abs(pc)]; if (pc 7 || pc -7) { // 过河兵额外加分 if (pc 0 r 4) v (r - 4) * 20; if (pc 0 r 5) v (4 - r) * 20; } if (pc 4 || pc -4) { // 马在中腹稍微加分 if (r 2 r 7 c 2 c 6) v 10; } score (pc 0) ? v : -v; } return score; }评估函数是搜索引擎里最值得花时间的部分子力价值表一旦调好棋力立刻上一个台阶比盲目加深搜索层数见效快得多。4.2 负极大值框架与 alpha-beta 剪枝搜索框架用负极大值配合 alpha-beta 剪枝。负极大值的写法简洁当前方的分数等于对方的分数取负递归里只需要一个函数处理双方。// 负极大值 alpha-beta 剪枝 // depth: 剩余搜索深度alpha/beta: 搜索窗口 int AlphaBeta(int depth, int alpha, int beta, int side) { if (g_timeUp) return 0; // 超时保护直接返回 if (depth 0) return Evaluate(g_board, side) * (side 0 ? 1 : -1); MoveList moves; GenMoves(g_board, side, moves); SortMoves(moves); // 吃子和将军排前面剪枝效率高 if (moves.n 0) // 无子可走 return IsChecked(g_board, side) ? -1000000 : 0; // 被将死或困毙 for (int i 0; i moves.n; i) { MakeMove(moves[i]); int score -AlphaBeta(depth - 1, -beta, -alpha, side ^ 1); UnmakeMove(moves[i]); if (score beta) return beta; // beta 剪枝 if (score alpha) alpha score; } return alpha; }参数常用值说明depth3 到 4开局 3 层够用残局可到 5 到 6 层alpha-1000000初始下界beta1000000初始上界时间预算800ms配合迭代加深超时用上次结果兜底棋力翻车最常见的原因不是层数不够而是没有走法排序。开局每步约 40 个候选走法4 层深度理论上要搜 40 的 4 次方个节点剪枝再好也扛不住。把吃子和将军的走法排到前面alpha-beta 的剪枝效率提高一个数量级同样的时间深度能多搜一层。4.3 迭代加深给搜索上一道「后悔药」固定深度搜索有个尴尬深度 4 可能刚好跑到一半超时此时没有任何可用着法。做法是迭代加深从深度 1 开始逐层加深每层完成后保留下一次搜索的排序依据超时就用最近一次完整计算的结果。// 迭代加深从浅到深超时用上一次完成深度的结果 int SearchRoot(int side) { int best 0; g_timeUp 0; int start GetTickCount(); for (int depth 1; depth kMaxDepth; depth) { best AlphaBeta(depth, -1000000, 1000000, side); if (g_timeUp) break; // 超时用上次深度算出的 best if (GetTickCount() - start 800) break; } return best; }SearchRoot 返回的是评估值真正要落子还得在搜索里记录最佳走法可以在 AlphaBeta 的入口处记录初始走法。迭代加深的另一个好处是浅层搜索结果可以作为深层排序的参考让剪枝更有效。5. 避坑GDI 闪烁、UI 卡死与棋规边界排查5.1 棋盘像闪光灯一样闪现象拖动窗口、切换程序再切回来棋盘区域持续闪烁棋子边缘能看到明显的重影。 原因常见的是两种叠加。第一WM_PAINT 里直接用 hdc 绘制擦除背景和重绘之间存在空窗期第二绘制逻辑里调用了 InvalidateRect 强制整窗口重绘把不该刷新的区域也刷新了。 解决双缓冲是必须的见第 2.3 节所有绘制先进内存 DC 再一次 BitBlt。另外刷新时机也要克制走子之后只调用 InvalidateRect(hwnd, NULL, FALSE)第三个参数传 FALSE 表示不擦除背景配合双缓冲基本能做到肉眼无闪烁。5.2 点击棋子后界面「假死」现象轮到 AI 思考时鼠标变成沙漏窗口拖不动、点最小化没反应几秒后才恢复。 原因搜索函数在主线程执行消息循环被阻塞。GetMessage 拿不到鼠标消息窗口自然没有任何响应。 解决把搜索放到独立线程算完用 PostMessage 通知主线程刷新。注意不能用 SendMessageSendMessage 会等接收方处理完才返回等于又阻塞回去。DWORD WINAPI SearchThread(LPVOID param) { int bestMove SearchRoot(g_side); // 在子线程里跑完整搜索 PostMessage(g_hwnd, WM_AI_DONE, bestMove, 0); // 通知主线程落子 return 0; }WM_AI_DONE 是自定义消息主线程收到后解析 bestMove更新棋盘并 InvalidateRect。还要在窗口关闭时处理线程收尾否则程序退出时子线程还在跑可能崩溃。方案是加一个 g_exit 标志搜索循环每次检查它退出前等待线程句柄超时再销毁窗口。5.3 AI 和人对将来回将军停不下来现象AI 用车或炮长将人每一步都只能应将AI 下回合又将军局面原地循环。 原因搜索只评估子力不知道局面重复会导致和棋。引擎觉得循环将军不亏子就一条路走到底。 解决加局面重复检测。用一个 Zobrist 哈希表记录当前局面的历史出现次数同一局面第三次出现判和。实践上不用等到第三次搜索节点里如果发现当前局面已经出现过一次直接返回一个接近 0 的分数引擎就会主动避开循环。// 简化思路哈希表记录局面出现次数 if (g_history[zobristHash] 2) return 0; // 重复局面按和棋处理Zobrist 哈希的要点是每个棋子在不同交叉点分配随机数走子时用异或更新哈希值这样更新代价是 O(1)。哈希碰撞概率极低工程上够用。5.4 黑方棋子画对了位置点击却没反应现象黑方棋子显示位置正常但点击黑子选不中或者选中了棋盘另一侧的交叉点。 原因绘制时可能统一用了红方视角的 row而鼠标命中测试没有对黑方做 row 翻转。中国象棋双方各坐一边红方 row 0 是底线黑方 row 9 才是底线同一棋盘数据对双方而言方向相反。 解决绘制和命中测试都走同一个视角转换函数。界面固定红方在下黑方棋子画在 row 9 到 6棋盘数组里的 row 就是实际的行不存在翻转问题但如果做「红黑对调视角」功能就要在鼠标事件里把 row 翻转成 9 减 row绘制时同样翻转。5.5 兵过河后横走把程序走崩溃现象玩家操作黑兵或红兵横向吃子程序偶发崩溃调试发现数组越界。 原因兵未过河不能横走这个判断写在 IsLegalMove 里但生成走法列表时如果单独写了一份逻辑两边规则不一致就会出现漏检。另一种情况是目标交叉点计算越界比如在底线按「前进」生成走法tr 变成 10。 解决生成走法时不要自己另写一套移动规则统一调 IsLegalMove目标超出棋盘时直接短路返回不合法。数组越界这类问题可以用断言拦截一次。崩溃发生的瞬间用调试器看调用栈基本能定位到是哪个方向的走法生成没有做边界判断。6. 进阶用残局用例验证你的引擎棋力6.1 三组残局用例引擎写完判断它有没有棋力的最快方式不是跟人下而是摆几个已知结论的残局跑一遍。建议准备三组双车错、马后炮、单兵擒王都能在几步内锁定胜局。可以用自定义局面串加载棋盘按行列出棋子代号红方大写黑方小写点表示空位这个格式类似象棋的 FEN 变体做加载函数时顺手兼容。6.2 验证方法与预期每组局面让引擎执先手限时 1 秒。双车错应该在两步内走出将死马后炮在三步内完成单兵擒王是残局基本功考察兵过河后的推进路径。如果某个残局总是走偏先用文本格式打印走法序列对照经典杀法看是哪一步评估错了再回头调位置分或搜索排序。6.3 两步有用的优化先做走法排序再做置换表顺序不能反。走法排序让剪枝率提升置换表才能把反复搜索的局面缓存住。这两步完成后同样的 800 毫秒预算大约能多搜一层深度棋力提升比调子力分更明显。我第一次做这套程序时把深度调到 6结果每步要等十几秒AI 还走出看不懂的废棋。后来想明白了在象棋里「看几步」不如「看好每一步」排序和评估的优先级远高于盲目加深。如果你也在这个方向上踩坑希望帮到你。本文还有配套的精品资源点击获取