
简介这份压缩包围绕Windows SDK下的象棋程序开发面向具备C基础、希望理解Win32桌面应用与棋类人工智能实现的初中级开发者。内容涉及GDI/GDI绘制棋盘与棋子、窗口消息响应、棋盘状态与棋子数据结构、合法走子与将军判断以及Minimax配合Alpha-Beta剪枝、蒙特卡洛树搜索等AI思路并延伸到多线程交互、错误处理和打包发布可作为课程设计或小型棋类项目的参考。整包共38个文件、约24KB包含18个ico图标、6个头文件、4个cpp实现、3个cur光标资源以及dsw/dsp工程文件、rc资源脚本和说明文本分别对应界面素材、接口声明、核心逻辑与工程组织能帮助读者观察资源脚本与窗口过程的配合关系。已有126人学习下载适合按模块阅读代码、理解Win32窗口与资源组织方式并借鉴消息驱动和棋类搜索算法的落地写法目录中调试、发布与资源分区清晰便于按界面、逻辑和工程配置逐层查阅。1. windows sdk 象棋拆包一个纯 Win32 的棋盘程序到底能练什么windows sdk 象棋这套源码最容易被低估的地方是它把“消息驱动”这四个字落到了实处棋盘不是画在一张静态图上而是靠 WM_PAINT、WM_LBUTTONDOWN、WM_TIMER 三条线串起来的。打开工程你会发现没有 MFC、没有 Qt、没有第三方 UI 库窗口、画笔、字体、鼠标命中全走原生 API。这类资源适合两类人一类是刚写完 Hello World 窗口、想找个完整交互程序练手的另一类是要做课程设计或小工具需要一份能讲清楚“状态怎么流转、界面怎么刷新”的参考实现。下面按我实际拆代码的顺序走先把窗口壳立住再画棋盘棋子然后写规则引擎最后接搜索中间把翻车点单独拎一章讲透。2. 窗口壳先立住消息循环、双缓冲与像素坐标Win32 程序的骨架十几年没变过但象棋这种“整块客户区自绘 高频鼠标交互”的场景对骨架有几个额外要求背景不能由系统刷、绘制不能直接落在屏幕 DC 上、坐标换算必须只有一个入口。这三件事没做对后面画得再漂亮也会闪、会偏、会点不准。2.1 从 wWinMain 到 WndProc能跑起来的最小骨架先看入口。这段代码的作用是注册窗口类、创建窗口、进消息循环所有绘制和交互都从 WndProc 分发出去。// 最小窗口骨架资源释放略 int APIENTRY wWinMain(HINSTANCE hInst, HINSTANCE, LPWSTR, int nCmdShow) { WNDCLASSEXW wc { sizeof(wc) }; wc.style CS_HREDRAW | CS_VREDRAW; wc.lpfnWndProc WndProc; wc.hInstance hInst; wc.hCursor LoadCursor(nullptr, IDC_ARROW); wc.hbrBackground (HBRUSH)GetStockObject(NULL_BRUSH); // 背景交给自绘 wc.lpszClassName LXiangqiWnd; RegisterClassExW(wc); HWND hWnd CreateWindowExW(0, wc.lpszClassName, L象棋, WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 760, 860, nullptr, nullptr, hInst, nullptr); ShowWindow(hWnd, nCmdShow); UpdateWindow(hWnd); MSG msg; while (GetMessageW(msg, nullptr, 0, 0)) { TranslateMessage(msg); DispatchMessageW(msg); } return (int)msg.wParam; }hbrBackground设成NULL_BRUSH是关键一步。默认情况下系统会用背景刷先把客户区擦一遍然后 WM_PAINT 再把棋盘覆盖上去两次填充之间就是肉眼可见的白闪。窗口类里声明“背景我不需要系统管”配合后面的双缓冲闪屏问题一次性解决。CS_HREDRAW | CS_VREDRAW表示宽高变化时整窗重绘对这个项目是合适的如果你要做局部刷新优化可以去掉但拉伸窗口时容易留残影。CreateWindowExW里给的 760×860 只是初始尺寸真正可用的客户区要减掉标题栏和边框。很多新手的棋盘画出来总是偏下就是因为拿窗口矩形当客户区算了正确做法是在 WM_SIZE 或 WM_PAINT 里GetClientRect现取。2.2 双缓冲别在 WM_PAINT 里直接往屏幕 DC 上画棋子在拖动、选中高亮在切换的时候如果每一笔都直接写到屏幕 DC用户看到的是“画一半”的中间态。双缓冲的思路是先在内存位图上画完整帧再一次 BitBlt 贴到屏幕。case WM_PAINT: { PAINTSTRUCT ps; HDC hdc BeginPaint(hWnd, ps); RECT rc; GetClientRect(hWnd, rc); HDC memDC CreateCompatibleDC(hdc); HBITMAP memBmp CreateCompatibleBitmap(hdc, rc.right, rc.bottom); HBITMAP oldBmp (HBITMAP)SelectObject(memDC, memBmp); FillRect(memDC, rc, (HBRUSH)GetStockObject(WHITE_BRUSH)); DrawBoard(memDC, rc); DrawPieces(memDC, rc); DrawSelection(memDC, rc); BitBlt(hdc, 0, 0, rc.right, rc.bottom, memDC, 0, 0, SRCCOPY); SelectObject(memDC, oldBmp); DeleteObject(memBmp); DeleteDC(memDC); EndPaint(hWnd, ps); return 0; } case WM_ERASEBKGND: return 1; // 告诉系统背景已处理省掉一次整窗填充CreateCompatibleBitmap的第一个参数必须是窗口 DChdc不能是刚创建出来的memDC。内存 DC 初始选中的是一张 1×1 的单色位图用它兼容出来的位图也是单色的画出来整个棋盘只有黑白两色线是黑的、底是白的棋子全变成黑块。这个坑几乎每个第一次写双缓冲的人都踩过而且报错信息为零只能靠肉眼发现。WM_ERASEBKGND直接返回 1 是配套动作。返回非零表示“背景我已经画了”系统就跳过填充。两个动作少一个闪屏都会回来。每次 WM_PAINT 都新建位图是有代价的窗口频繁重绘时 GDI 对象创建销毁的开销不小。常见做法是把 memDC 和 memBmp 做成全局或窗口属性在 WM_SIZE 里重建、在 WM_DESTROY 里释放。但要注意窗口尺寸变化时必须重新创建位图否则 BitBlt 贴出来的还是旧尺寸的画面右边和下边会出现黑边。2.3 棋盘坐标与像素坐标互转只留一个入口象棋棋盘是 9 条竖线、10 条横线共 90 个交叉点。所有绘制和点击判断都依赖“第几列第几行”和“屏幕像素”之间的换算。这段换算如果不收敛到一个函数里后面必然出现“画的位置和点的位置差半格”的问题。struct BoardGeom { int margin; // 外框到第一条线的距离 int cell; // 相邻两条线的间距 int left, top, width, height; }; // 以短边为准算几何保证棋盘不被拉变形 BoardGeom CalcGeom(const RECT rc) { BoardGeom g{}; int w rc.right - rc.left; int h rc.bottom - rc.top; int side min(w, h); g.margin side / 20; g.cell (side - 2 * g.margin) / 8; // 横向 8 格 g.width g.cell * 8 2 * g.margin; g.height g.cell * 9 2 * g.margin; // 纵向 9 格 g.left (w - g.width) / 2; g.top (h - g.height) / 2; return g; } POINT CellToPixel(const BoardGeom g, int col, int row) { return { g.left g.margin col * g.cell, g.top g.margin row * g.cell }; } bool PixelToCell(const BoardGeom g, int x, int y, int col, int row) { int cx x - g.left - g.margin; int cy y - g.top - g.margin; if (cx -g.cell / 3 || cy -g.cell / 3) return false; col (cx g.cell / 2) / g.cell; row (cy g.cell / 2) / g.cell; return col 0 col 9 row 0 row 10; }横向 8 格、纵向 9 格这是中国象棋和其他棋类在画法上最容易写错的常数。9 条竖线之间有 8 个间隔10 条横线之间有 9 个间隔写反了棋盘会被压扁或拉长而棋子位置看起来“差不多对”特别难排查。PixelToCell里加g.cell / 2是四舍五入到最近的交叉点。不加的话点在交叉点右下方 1 像素就会落到下一格玩起来像“点不中”。cell / 3的容差是给棋盘外沿留一点点击余量边线上的棋子不至于因为点偏两像素就选不中。参数怎么调margin用短边的 1/20棋盘大约占客户区 90%视觉上比较饱满想留白多改成 1/12。cell必须是整数否则每条线的累计误差会让最后一条线偏 1~2 像素。单看线不明显但棋子圆心和交叉点会越靠右越不重合强迫症看着很难受。高 DPI 是这一段最容易被忽略的延伸问题。不声明 DPI 感知的话系统会把整个窗口拉伸文字和圆形边缘都会糊。启动时调一次SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)并在WM_DPICHANGED里按新的 DPI 重新调用CalcGeom棋盘和字体才会始终清晰。3. GDI 画棋盘九宫、河界、兵炮位与棋子的字形居中窗口壳立住之后绘制层就是纯 GDI 的活了。这一层的难点不在 API而在几何细节哪些线要断开、九宫斜线连哪两个点、兵炮位的小标记画几笔、字怎么在圆里居中。每一个都是“画错一眼能看出来但不知道错在哪”的类型。3.1 棋盘几何九条竖线、十条横线、两处九宫外框用 Rectangle横线十条逐条画竖线九条要在河界处断开九宫的斜线只在上下两个 3×3 区域里出现。void DrawBoard(HDC hdc, const BoardGeom g) { HPEN pen CreatePen(PS_SOLID, 2, RGB(60, 40, 20)); HPEN oldPen (HPEN)SelectObject(hdc, pen); HBRUSH oldBr (HBRUSH)SelectObject(hdc, GetStockObject(NULL_BRUSH)); POINT p00 CellToPixel(g, 0, 0); POINT p89 CellToPixel(g, 8, 9); Rectangle(hdc, p00.x, p00.y, p89.x, p89.y); // 外框 for (int r 1; r 9; r) { // 内部 9 条横线 POINT a CellToPixel(g, 0, r); POINT b CellToPixel(g, 8, r); MoveToEx(hdc, a.x, a.y, nullptr); LineTo(hdc, b.x, b.y); } for (int c 0; c 9; c) { // 9 条竖线 POINT top CellToPixel(g, c, 0); POINT r4 CellToPixel(g, c, 4); POINT r5 CellToPixel(g, c, 5); POINT bot CellToPixel(g, c, 9); if (c 0 || c 8) { // 边线贯通 MoveToEx(hdc, top.x, top.y, nullptr); LineTo(hdc, bot.x, bot.y); } else { // 内线在河界断开 MoveToEx(hdc, top.x, top.y, nullptr); LineTo(hdc, r4.x, r4.y); MoveToEx(hdc, r5.x, r5.y, nullptr); LineTo(hdc, bot.x, bot.y); } } // 上下九宫的斜线 POINT t1 CellToPixel(g, 3, 0), t2 CellToPixel(g, 5, 2); POINT t3 CellToPixel(g, 5, 0), t4 CellToPixel(g, 3, 2); MoveToEx(hdc, t1.x, t1.y, nullptr); LineTo(hdc, t2.x, t2.y); MoveToEx(hdc, t3.x, t3.y, nullptr); LineTo(hdc, t4.x, t4.y); POINT b1 CellToPixel(g, 3, 7), b2 CellToPixel(g, 5, 9); POINT b3 CellToPixel(g, 5, 7), b4 CellToPixel(g, 3, 9); MoveToEx(hdc, b1.x, b1.y, nullptr); LineTo(hdc, b2.x, b2.y); MoveToEx(hdc, b3.x, b3.y, nullptr); LineTo(hdc, b4.x, b4.y); SelectObject(hdc, oldBr); SelectObject(hdc, oldPen); DeleteObject(pen); }Rectangle会同时用当前画刷填充内部如果画刷没切成NULL_BRUSH整个棋盘内部会被刷成实心色块线全被盖住。这个顺序问题是 GDI 绘制的通用陷阱任何“描边 填充”的图形都要先把填充画刷和描边画笔分别选好。兵位和炮位的小标记容易被省掉但它是棋盘的辨识度来源。兵位在 row 3 和 row 6 的偶数列炮位在 row 2 和 row 7 的第 1、7 列。每个位置画四段短线边线上的col 0 和 col 8只画朝向棋盘内侧的一半否则短线会画到棋盘外面去。河界两条边线之间通常还会写“楚河”“汉界”四个字。SetTextAlign(TA_CENTER)配合TextOutW在河界中间位置输出就行字号取cell * 0.8太大挤到边上太小看不见。3.2 棋子圆、字、颜色以及那个 0.20 的基线偏移棋子是圆底加一个字。圆用Ellipse字用TextOutW难点是让“車”“馬”这种笔画多的字在圆里视觉居中。void DrawPiece(HDC hdc, const BoardGeom g, int col, int row, int piece) { POINT c CellToPixel(g, col, row); int r (int)(g.cell * 0.45); HBRUSH face CreateSolidBrush(RGB(245, 222, 179)); HBRUSH oldBr (HBRUSH)SelectObject(hdc, face); HPEN edge CreatePen(PS_SOLID, 2, RGB(80, 50, 20)); HPEN oldPen (HPEN)SelectObject(hdc, edge); Ellipse(hdc, c.x - r, c.y - r, c.x r, c.y r); LOGFONTW lf {}; lf.lfHeight -(int)(g.cell * 0.55); // 负值表示字符高度 lf.lfWeight FW_BOLD; wcscpy_s(lf.lfFaceName, LKaiTi); HFONT font CreateFontIndirectW(lf); HFONT oldFont (HFONT)SelectObject(hdc, font); SetBkMode(hdc, TRANSPARENT); SetTextColor(hdc, piece 0 ? RGB(200, 0, 0) : RGB(0, 0, 0)); SetTextAlign(hdc, TA_CENTER | TA_BASELINE); const wchar_t* name PieceName(piece); TextOutW(hdc, c.x, c.y (int)(g.cell * 0.20), name, (int)wcslen(name)); SelectObject(hdc, oldFont); DeleteObject(font); SelectObject(hdc, oldBr); DeleteObject(face); SelectObject(hdc, oldPen); DeleteObject(edge); }lfHeight取负值是“字符高度”取正值是“单元格高度”。用正值的话字体自带的上下留白会让字整体偏高、顶到圆的上沿看起来像没居中。负值配合TA_BASELINE再用cell * 0.20下移基线才能让字形在圆里视觉平衡。这个 0.20 不是通用常数。换本文还有配套的精品资源点击获取