资讯动态

Win32 SDK中国象棋源码:消息循环、双缓冲与规则引擎实战

发布时间:2026/10/27 4:26:45 来源:尧图企业网站定制
简介面向 Windows 平台象棋程序开发的学习型代码包适合具备 C 基础、希望借助 Windows SDK 理解桌面棋类应用组织方式的开发者参考。压缩包共三十八个文件约二十四KB包含四个 C 源文件、六个头文件、一个资源脚本以及工程配置文件并配有图标、光标和说明文档便于在旧版 Visual Studio 环境中打开工程、查看资源布局与模块划分。已有 126 人学习。内容围绕棋盘棋子绘制、鼠标消息响应、棋局状态表示、合法走法与将军判断、AI 思考等环节展开可据此梳理 GDI 绘图、消息机制、搜索算法在象棋程序中的具体落地。对理解头文件、库文件、文档与示例代码之间的协作关系也有帮助适合按模块阅读、对照实验并为课程设计、小型项目拆解和二次开发提供可参考的代码骨架与工程配置思路。1. 从一份能真正跑起来的 Win32 象棋源码切入很多人学完 Win32 SDK 的消息循环后项目反而卡住了窗口能弹出来鼠标也能点但真要做一个完整的小软件就无从下手。这份 windows sdk 象棋源码正好卡在这个缺口上——一份不依赖第三方库、双击即开的中国象棋工程从WM_PAINT的双缓冲绘制、鼠标点击的像素到交叉点换算到马腿、象眼、炮架这类规则判定全在一套代码里跑通。它适合两类人刚啃完窗口编程想找一个能动手改的小项目练手的以及手上有个小工具需求需要一份纯 Win32 界面模板、不想被任何框架绑架的。棋盘数据、渲染、规则三层是拆开的改规则不用碰绘制代码这是我愿意把它拿出来讲的主要原因。2. 工程骨架与消息驱动代码分布、运行模型与三条编译路径2.1 从 wWinMain 到消息循环整份代码的驱动力在哪Win32 象棋和基于框架的项目最大的区别是没有“主循环”这个显式概念。程序启动之后除了初始化那几十行剩下的时间全泡在GetMessage里所有的走子、重绘、结束游戏都是被一条条消息从系统那边推进来的。理解这一点后面看任何函数都不会迷路。下面这段是入口也是全工程唯一一次“主动”执行代码的地方// main.cpp —— 程序入口注册窗口类、建窗、进消息循环 int APIENTRY wWinMain(HINSTANCE hInst, HINSTANCE hPrev, LPWSTR cmdLine, int nShow) { // 1. 注册窗口类WndProc 是所有鼠标/绘制消息的落点 WNDCLASSEX wc { sizeof(WNDCLASSEX) }; 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; if (!RegisterClassEx(wc)) return 0; // 2. 按棋盘的客户区尺寸反推窗口外框保证棋盘刚好装下、不多不少 RECT rc { 0, 0, kBoardW, kBoardH }; AdjustWindowRect(rc, WS_OVERLAPPEDWINDOW, FALSE); HWND hWnd CreateWindow(LXiangqiWnd, L中国象棋, WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, rc.right - rc.left, rc.bottom - rc.top, nullptr, nullptr, hInst, nullptr); ShowWindow(hWnd, nShow); UpdateWindow(hWnd); // 立刻塞一条 WM_PAINT别等系统心情 // 3. 消息循环直到 WM_QUIT 才退出 MSG msg; while (GetMessage(msg, nullptr, 0, 0)) { TranslateMessage(msg); // 把 WM_KEYDOWN 翻译成 WM_CHAR快捷键要用 DispatchMessage(msg); // 分发到 WndProc } return (int)msg.wParam; }几个参数值得单独说。hInst是模块实例句柄注册窗口类和加载资源都要它别随手传nullptrnShow来自系统直接喂给ShowWindow不要硬写SW_SHOW否则从快捷方式最小化启动时会不对。AdjustWindowRect这一步很多人省掉结果客户区被标题栏和边框吃掉几十像素棋盘右下角永远缺一块调坐标调到怀疑人生。hbrBackground用NULL_BRUSH而不是(HBRUSH)(COLOR_WINDOW1)是因为背景刷会在WM_ERASEBKGND阶段先刷一遍再被我们的双缓冲盖一遍中间那一帧空白就是闪烁的来源——这个玄学问题后面第 5 章还会再踩一次。游戏状态本身是一个文件级全局对象WndProc拿它、绘制函数拿它、规则函数也拿它。小工程这么做没毛病比到处传指针清爽得多真要重构成类把Board挂到GWLP_USERDATA上就行改动量不超过二十行。2.2 目录结构三层拆分决定了你改代码时的舒服程度工程不大但文件边界划得比较清楚基本遵循“数据、规则、绘制、消息”四分文件职责大概行数改动频率main.cpp入口、窗口类注册、消息循环60 行极低wndproc.cpp消息分发处理WM_PAINT、WM_LBUTTONDOWN、WM_COMMAND300 行中board.h/board.cpp棋盘数组、走法生成、将军判定、悔棋栈700 行高render.cpp双缓冲、棋盘线、棋子、选中高亮350 行中res/棋子位图、窗口图标、菜单资源二进制低xiangqi.rc资源脚本声明菜单和图标40 行低board.cpp是唯一值得反复动的地方。它对外只暴露四个接口at(row, col)取子、DoMove(move)落子、IsLegalMove(move)判合法、Undo()退一步。渲染层完全不知道规则长什么样只负责把 10×9 的数组画出来消息层也只管把像素坐标翻译成(col, row)然后问board一句“这步行不行”。这个边界一旦守住你后面加 AI、加开局库、加打谱功能都不用动render.cpp一行。棋盘用定长二维数组而不是std::vector或链表理由很实在10×9 90 个格子值拷贝一次才 90 字节做试算判断走完这步会不会被将时直接Board tmp b;就完事不用写复杂的深拷贝。我一般会把这个数组放在结构体里顺手带上turn轮到谁走和halfMove走了多少步用于悔棋栈索引。2.3 三条编译路径按你手头有什么工具挑一条最省事的是用 IDE 建一个空的 Windows 桌面项目把.cpp全加进去然后在项目属性里确认两点字符集设为 Unicode子系统设为 Windows。字符集这一项如果留在“多字节”L中国象棋这类宽字符串会直接编译报错新手有一半时间耗在这。没有工程文件也能编。打开开发者命令行一条命令搞定# 在带 MSVC 环境的命令行里直接编译不依赖 .sln cl /nologo /EHsc /W4 /DUNICODE /D_UNICODE ^ main.cpp wndproc.cpp board.cpp render.cpp ^ /link /SUBSYSTEM:WINDOWS user32.lib gdi32.lib /OUT:xiangqi.exe/SUBSYSTEM:WINDOWS告诉链接器入口是wWinMain而不是main选错了会报LNK2019 无法解析的外部符号 main这个报错和代码逻辑一点关系都没有纯配置问题。user32.lib和gdi32.lib必须显式列上因为工程没开自动链接头文件里的#pragma comment。/W4建议保留括号优先级、有符号无符号比较这类警告在坐标计算里往往就是真 bug。第三种是 CMake适合你打算把这个工程当模板长期用cmake_minimum_required(VERSION 3.20) project(xiangqi CXX) add_executable(xiangqi WIN32 main.cpp wndproc.cpp board.cpp render.cpp xiangqi.rc) # .rc 直接塞进 add_executable 即可 target_compile_definitions(xiangqi PRIVATE UNICODE _UNICODE) target_link_libraries(xiangqi PRIVATE user32 gdi32) set_target_properties(xiangqi PROPERTIES WIN32_EXECUTABLE TRUE)WIN32关键字和WIN32_EXECUTABLE是同一个意思的两种写法前者作用于add_executable后者是属性写法选一个即可重复设置不报错但没必要。资源脚本一定要列进去否则窗口图标会是系统默认的白色方块编译能过、跑起来难看很容易以为是绘制代码的问题。3. 棋盘与棋子渲染一帧画面从像素换算到 BitBlt 的完整链路3.1 坐标体系交叉点下标与像素的互转象棋棋盘不是格子是交叉点。9 条纵线、10 条横线棋子落在交点上而不是格子里。这一条决定了所有坐标换算都围绕“最近的交叉点”展开。先把常量定死// board.h —— 棋盘几何常量改这里就能整体缩放 constexpr int kCols 9; // 纵线 9 条 constexpr int kRows 10; // 横线 10 条 constexpr int kCell 60; // 相邻交叉点的像素间距 constexpr int kMarginX 46; // 棋盘左内边距留给边框和坐标数字 constexpr int kMarginY 46; // 上内边距 constexpr int kPieceR 26; // 棋子半径略小于半格留缝不粘边 constexpr int kBoardW kMarginX * 2 kCell * (kCols - 1); // 46*2 60*8 572 constexpr int kBoardH kMarginY * 2 kCell * (kRows - 1); // 46*2 60*9 632kCell 60是我试出来的舒服值再小人眼看不清“马”和“車”的差别再大普通笔记本屏幕就装不下还得滚动。整套尺寸全部由这三个量推导你想做成 800×900 的大棋盘只改kCell一个数就够其余自动跟着走。像素反查下标是交互的核心也是最容易写出边界 bug 的地方// board.h —— 像素坐标 - 交叉点下标落点太偏就判为无效点击 inline bool PixelToGrid(int px, int py, int col, int row) { // 先粗筛距离棋盘外框超过一格的点直接不算 if (px kMarginX - kCell / 2 || py kMarginY - kCell / 2) return false; // 加半格再整除等价于四舍五入到最近的交叉点 col (px - kMarginX kCell / 2) / kCell; row (py - kMarginY kCell / 2) / kCell; if (col 0 || col kCols || row 0 || row kRows) return false; // 再核一次到交叉点的真实距离超过 0.8 格认为是点歪了 const int dx px - (kMarginX col * kCell); const int dy py - (kMarginY row * kCell); const int lim kCell * 4 / 5; return dx * dx dy * dy lim * lim; }函数最后的距离校验不能省。少了它点在两个交叉点正中偏一点的位置也会被“四舍五入”归到其中一侧手感上会出现“明明没点中棋子却选中了”的错觉。判据用平方比较而不是sqrt是为了避开浮点和-lm依赖整数乘法在这个量级上误差为零。还有一处要注意C 的整数除法是向零截断不是向下取整。(-10 30) / 60得 0而数学上应该得 -1。所以上面的粗筛那一步必须放在除法之前用px kMarginX - kCell/2把负数区间整个挡掉否则左上角附近会出现“点外框反而选中第 0 列”的怪事。3.2 双缓冲绘制一帧棋盘从内存位图到屏幕Win32 里闪烁的根源永远是“画了两次”。一次是背景擦除一次是前景绘制两次之间屏幕显示的是中间态人眼就看见闪。双缓冲的思路很朴素——所有绘制都进内存里的一张位图画完了一次性BitBlt贴到窗口 DC 上屏幕上永远只看到完整帧。// render.cpp —— 每帧完整重绘先画进内存 DC最后一次性贴屏 void DrawFrame(HWND hWnd, HDC hdc) { // 后备缓冲全局只建一次尺寸是固定棋盘尺寸不随窗口变窗口不可缩放 static HDC memDC nullptr; static HBITMAP memBmp nullptr; static HGDIOBJ oldBmp nullptr; if (!memDC) { memDC CreateCompatibleDC(hdc); memBmp CreateCompatibleBitmap(hdc, kBoardW, kBoardH); oldBmp SelectObject(memDC, memBmp); // 记住旧位图退出时还原 } // 1. 清底米黄色棋盘面 RECT all { 0, 0, kBoardW, kBoardH }; FillRect(memDC, all, (HBRUSH)GetStockObject(WHITE_BRUSH)); // 2. 画棋盘外框、9 纵 10 横、楚河汉界、九宫斜线、炮兵位十字 DrawGrid(memDC); // 3. 画棋子遍历 10x9 数组非空就画一枚 const Board b GetBoard(); for (int r 0; r kRows; r) for (int c 0; c kCols; c) if (b.at(r, c) ! EMPTY) DrawPiece(memDC, c, r, b.at(r, c)); // 4. 画选中框和可落点提示 if (HasSelection()) DrawSelection(memDC, selCol, selRow); // 5. 一次性贴到窗口 DC屏幕上只出现完整帧 BitBlt(hdc, 0, 0, kBoardW, kBoardH, memDC, 0, 0, SRCCOPY); }CreateCompatibleDC(hdc)创建的是内存设备上下文初始时里面只有一张 1×1 的单色位图必须马上SelectObject一张同尺寸的位图进去否则后面所有绘制都画在 1×1 的角落里贴出来一片黑。这是新手最常翻的车之一编译零警告、运行一片黑很难查。SelectObject的返回值一定要接住。GDI 对象是引用计数的你不把旧对象换回来就DeleteObject会导致资源释放异常。上面用static缓存句柄是为了避免每次WM_PAINT都创建、销毁一次位图——WM_PAINT在拖动窗口时可能一秒触发几十次反复创建大位图会肉眼可见地卡。代价是这块内存直到进程退出才释放对单窗口小工具来说完全可以接受。清屏用FillRect而不是PatBlt或SetBkColor Clear是因为FillRect支持任意画刷后面想加木纹背景贴图换个HBRUSH就行函数体不用动。WM_PAINT的处理要保持极短只做三件事BeginPaint、调DrawFrame、EndPaint。任何规则计算、文件读写都不要塞进来BeginPaint和EndPaint之间待得越久系统越容易认为你卡死。// wndproc.cpp —— WM_PAINT 只负责触发一帧逻辑越薄越好 case WM_PAINT: { PAINTSTRUCT ps; HDC hdc BeginPaint(hWnd, ps); // 必须在 WM_PAINT 里配对调用 DrawFrame(hWnd, hdc); EndPaint(hWnd, ps); return 0; }走完一步棋之后不能直接调DrawFrame而要InvalidateRect(hWnd, nullptr, FALSE)让系统记一个“这块要重画”的标记等消息队列空了自然发WM_PAINT。第三个参数传FALSE表示不擦背景正好配合我们的双缓冲传TRUE的话系统还是会先刷一遍背景刷闪烁又回来了。3.3 棋子绘制GDI 画圆配文字还是贴位图棋子有两种画法。贴位图好看但要做 7 种棋子 × 2 种颜色 14 张图还得处理缩放纯 GDI 画圆加文字丑一点但零资源依赖、改个字换个颜色就是一行代码。这份工程选的是后者理由是“能跑起来、能改”优先于“好看”。// render.cpp —— 一枚棋子圆底 描边 居中的汉字 void DrawPiece(HDC dc, int col, int row, const Piece p) { const int x kMarginX col * kCell; const int y kMarginY row * kCell; // 1. 圆底白方用偏暖的白黑方用米黄靠底色区分阵营 HBRUSH fill CreateSolidBrush(p.side RED ? RGB(255, 252, 245) : RGB(248, 238, 214)); HGDIOBJ oldBrush SelectObject(dc, fill); HGDIOBJ oldPen SelectObject(dc, GetStockObject(BLACK_PEN)); Ellipse(dc, x - kPieceR, y - kPieceR, x kPieceR, y kPieceR); SelectObject(dc, oldBrush); SelectObject(dc, oldPen); DeleteObject(fill); // 自建画刷必须删否则 GDI 句柄一路涨 // 2. 汉字透明背景 矩形居中比 SetTextAlign 少踩坐标系坑 SetBkMode(dc, TRANSPARENT); SetTextColor(dc, p.side RED ? RGB(186, 28, 28) : RGB(24, 24, 24)); RECT rc { x - kPieceR, y - kPieceR, x kPieceR, y kPieceR }; DrawTextW(dc, p.name, -1, rc, DT_CENTER | DT_VCENTER | DT_SINGLELINE); }Ellipse的四个参数是外接矩形的左右上下不是圆心加半径写成(x, y, x r, y r)会得到一堆只有四分之一大的小圆弧。DeleteObject那行不能省——CreateSolidBrush每调用一次就消耗一个 GDI 句柄默认进程上限是一万个看着很多但一秒重绘几十帧的话撑不了多久就会画不出东西表现是“跑一会儿界面就白了”特别像内存泄漏其实是句柄泄漏。字体这里故意没设用系统默认的SYSTEM_FONT。想好看一点在WM_CREATE里建一个字体// wndproc.cpp —— WM_CREATE 里统一建字体别在绘制里反复 CreateFont case WM_CREATE: { g_hFont CreateFontW(-kPieceR, 0, 0, 0, FW_BOLD, FALSE, FALSE, FALSE, DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, DEFAULT_PITCH | FF_DONTCARE, L楷体); return 0; } // WM_DESTROY 里记得删 case WM_DESTROY: if (g_hFont) { DeleteObject(g_hFont); g_hFont nullptr; } PostQuitMessage(0); return 0;字号传负值表示“字符高度”而不是“字符宽度”这是CreateFont里少数几个反直觉的地方写成正数会得到一套宽扁得没法看的字。CLEARTYPE_QUALITY打开亚像素抗锯齿在小字号下比DEFAULT_QUALITY清楚一截。选中高亮不用半透明GDI 没有原生 alpha画一个红色虚线圆环就够醒目// render.cpp —— 选中框虚线圆环 四个角的短标记 void DrawSelection(HDC dc, int col, int row) { const int x kMarginX col * kCell; const int y kMarginY row * kCell; HPEN pen CreatePen(PS_DASH, 2, RGB(220, 60, 20)); HGDIOBJ oldPen SelectObject(dc, pen); HGDIOBJ oldBrush SelectObject(dc, GetStockObject(NULL_BRUSH)); // 空心 Ellipse(dc, x - kPieceR - 3, y - kPieceR - 3, x kPieceR 3, y kPieceR 3); SelectObject(dc, oldPen); SelectObject(dc, oldBrush); DeleteObject(pen); }PS_DASH只对宽度为 1 的画笔有效宽度设成 2 以上会自动退化成实线——这是 GDI 的既定行为不是你的代码写错了。想要粗虚线只能自己一段段画短线段或者干脆用PS_DOT配合 1 像素宽。4. 走子规则引擎从鼠标命中到合法着法生成4.1 点击命中与三段式选中状态机交互这一层的核心是一个状态机只有两个状态没选中、已选中。三种点击结果对应三条分支——点自己的子就选它点已选中子的合法落点就走其他情况一律取消。写成if-else链就够不需要上状态模式。// wndproc.cpp —— 左键点击的三段式判定 case WM_LBUTTONDOWN: { int col 0, row 0; if (!PixelToGrid(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam), col, row)) break; // 点在棋盘外什么都不做 Board b GetBoard(); if (!HasSelection()) { // 第一段只能选中当前行棋方的子 if (b.at(row, col).side b.turn) SetSelection(col, row); } else { Move m { selCol, selRow, col, row }; if (b.at(row, col).side b.turn) { // 第二段点到自己另一个子 改选不判合法性 SetSelection(col, row); } else if (IsLegalMove(b, m)) { // 第三段合法就落子别忘记录悔棋栈 b.DoMove(m); ClearSelection(); CheckGameOver(b); // 判断将死 / 困毙内部会弹提示 } else { ClearSelection(); // 非法落点取消选中不弹窗 } } InvalidateRect(hWnd, nullptr, FALSE); return 0; }GET_X_LPARAM/GET_Y_LPARAM这两个宏必须用不能直接LOWORD(lParam)。因为lParam里的坐标是有符号 16 位鼠标拖到窗口左侧外面时坐标会变成负数LOWORD拿到的是补码一取就变成一个六万多的正数于是“拖出去点一下”会把棋子甩到棋盘另一边。这个宏定义在windowsx.h里记得#include。点自己另一个子时改选而不是报错这个细节决定了手感。不加这条用户想换个子得先点空白处取消多一步操作用起来别扭。4.2 兵种走法马腿、炮架、象眼的统一处理IsLegalMove是总闸门先做通用过滤再分派到兵种规则最后统一做一次“走后不能被将”的试算。顺序很重要——通用过滤最便宜放最前面试算最贵要复制棋盘放最后。// board.cpp —— 合法性总闸门四层过滤从便宜到贵 bool IsLegalMove(const Board b, Move m) { // 第 1 层范围与空子校验 if (!b.InBounds(m.toCol, m.toRow)) return false; if (b.at(m.fromRow, m.fromCol) EMPTY) return false; if (m.fromCol m.toCol m.fromRow m.toRow) return false; // 第 2 层不能吃自己的子 const Piece dst b.at(m.toRow, m.toCol); if (dst ! EMPTY dst.side b.at(m.fromRow, m.fromCol).side) return false; // 第 3 层兵种自身规则 if (!PieceRuleOk(b, m)) return false; // 第 4 层走后不能让自己被将也不能形成将帅照面 return !LeaveKingInCheck(b, m); }马和炮是最能暴露问题的两个兵种一个怕腿被堵一个怕中间的子数不对。// board.cpp —— 马八方向查表顺带判蹩马腿 static bool HorseOk(const Board b, Move m) { // dc/dr 是落点相对起点的偏移lc/lr 是马腿相对起点的偏移 struct { int dc, dr, lc, lr; } tbl[8] { { 1, -2, 0, -1 }, { 2, -1, 1, 0 }, { 2, 1, 1, 0 }, { 1, 2, 0, 1 }, {-1, -2, 0, -1 }, {-2, -1, -1, 0 }, {-2, 1, -1, 0 }, {-1, 2, 0, 1 } }; for (const auto t : tbl) { if (m.toCol - m.fromCol ! t.dc || m.toRow - m.fromRow ! t.dr) continue; // 马腿坐标 起点 半程偏移腿上有子则该方向作废 int legC m.fromCol t.lc; int legR m.fromRow t.lr; return b.at(legR, legC) EMPTY; } return false; // 不在八个方向上直接不合法 }查表法的好处是马腿偏移和落点偏移写在同一行想核对逻辑一眼就能看完比一堆if判断安全得多。这八组数据我自己手写过三次每次都会把某一组的腿偏移写反症状是“马能斜着穿过一个子”非常隐蔽。炮的规则一句话能说清但写起来容易在循环边界上栽跟头// board.cpp —— 炮走直线不吃子时中间必须空吃子时中间必须恰好一个炮架 static bool CannonOk(const Board b, Move m) { if (m.fromCol ! m.toCol m.fromRow ! m.toRow) return false; // 只能走横竖 const int dc (m.toCol m.fromCol) - (m.toCol m.fromCol); // -1 / 0 / 1 const int dr (m.toRow m.fromRow) - (m.toRow m.fromRow); int between 0; // 路径上夹着的棋子数 // 从起点的下一格开始走到落点的前一格为止 int c m.fromCol dc, r m.fromRow dr; while (c ! m.toCol || r ! m.toRow) { if (b.at(r, c) ! EMPTY) between; c dc; r dr; } const bool dstEmpty (b.at(m.toRow, m.toCol) EMPTY); return dstEmpty ? (between 0) : (between 1); }dc和dr用比较结果相减得到 -1/0/1比自己写if分支干净也顺手保证了横走时dr 0、竖走时dc 0。循环条件是c ! m.toCol || r ! m.toRow用||不是——只要还有一维没到终点就继续走写成会在斜向其实不会出现和同轴时提前退出。这个|| / 的坑我在别的扫描逻辑里也翻过事后看代码完全看不出问题只能靠单元测试兜。象和士的“不能出宫 / 不能过河”用坐标范围卡死比用距离算更稳// board.cpp —— 象走田、塞象眼、不过河 static bool ElephantOk(const Board b, Move m) { if (std::abs(m.toCol - m.fromCol) ! 2 || std::abs(m.toRow - m.fromRow) ! 2) return false; // 塞象眼田字中心有子则作废 const int eyeC (m.fromCol m.toCol) / 2; const int eyeR (m.fromRow m.toRow) / 2; if (b.at(eyeR, eyeC) ! EMPTY) return false; // 不过河约定 row 0..4 是黑方半场5..9 是红方半场 const bool fromRedSide (m.fromRow 5); if (fromRedSide m.toRow 5) return false; if (!fromRedSide m.toRow 5) return false; return true; }半边场的划分一定要在board.h里写成宏或constexpr别在十个地方各写一遍 5。将来改成“红方在下”或者加个翻转棋盘的按钮只要改一处。4.3 将军、照面与胜负判定“走这步会不会被将”是所有判定里最贵的一环做法也最直接把棋盘整个拷一份把这步落下去然后看自己的将是不是正被对方攻击。// board.cpp —— 试算落子后己方是否处于被将状态或将帅照面 bool LeaveKingInCheck(const Board b, Move m) { Board tmp b; // 10x9 定长数组值拷贝约 90 字节很便宜 tmp.DoMoveUnchecked(m); // 不做合法性校验的直接落子专供试算 return tmp.InCheck(b.turn) || KingsFace(tmp); }DoMoveUnchecked和DoMove要分开写。前者只管改数组后者还要压悔棋栈、切换行棋方、清选中状态。混用的话试算过程会把悔棋栈污染得一塌糊涂用户按一次悔棋可能倒退三四步。将帅照面单独判因为它不算“被将”但同样是非法局面// board.cpp —— 将帅照面同列且中间无子等价于送将 static bool KingsFace(const Board b) { const int c1 b.kingCol(RED), r1 b.kingRow(RED); const int c2 b.kingCol(BLACK), r2 b.kingRow(BLACK); if (c1 ! c2) return false; const int lo (r1 r2 ? r1 : r2) 1; const int hi (r1 r2 ? r2 : r1); for (int r lo; r hi; r) if (b.at(r, c1) ! EMPTY) return false; return true; // 中间全空 照面 }胜负判定只做两件事轮到的这一方还有没有合法着法。一个都没有就是输将死或困毙象棋里都算输。实现上把所有己方棋子遍历一遍对每个子尝试 90 个落点找到一个合法的就退出。这个双重循环在最坏情况下是 90×90 8100 次IsLegalMove每次里面还有一次棋盘拷贝单次判定大概几十毫秒人感觉不到但如果你打算把它塞进 AI 的搜索循环里就必须优化了那是第 6 章的事。5. 常见问题排查GDI 闪烁、坐标偏差与规则漏判的五个坑5.1 拖动窗口或切到前台再切回来棋盘花成一片现象把窗口拖出屏幕边缘再拖回来或者被其他窗口遮住再露出来棋盘只剩零散的几枚棋子和半截线其余是灰白。原因一是没在窗口类里设CS_HREDRAW | CS_VREDRAW系统只重绘“无效区域”那一小块二是WM_PAINT里直接往窗口 DC 上画没有后备缓冲重绘区域和残留内容拼在一起。这两个原因经常同时存在。解决窗口类补上这两个 style再把绘制整体改成 3.2 节的DrawFrame模式每帧在内存位图上从清底开始全量重画不做任何“只画变化部分”的增量优化。棋盘总共就 90 个格子全量重画的开销远小于维护脏矩形带来的复杂度。5.2 点在两个交叉点正中间选中的是旁边那枚子现象鼠标明明点在这枚棋子的右边缘选中的却是右边那颗。原因PixelToGrid只做了四舍五入整除没做距离校验落点会无条件吸附到最近的交叉点。棋子半径 26 像素、格距 60 像素两个棋子之间的空隙只有 8 像素宽落在这 8 像素里的时候就纯粹看运气了。解决补上 3.1 节里那段dx*dx dy*dy lim*lim的判断把“离交叉点超过 0.8 格”的点击直接丢掉。另一种思路是把判定改成“点到圆心的距离小于棋子半径”手感更严格但点在棋子边缘会失效反而不如距离校验宽容。5.3 马能穿子炮能隔两个子吃现象马被自己的兵堵住腿依然能跳过去炮和目标之间隔了两个子照样吃掉。原因马腿偏移表里某一组的lc/lr写反了比如把{2, -1, 1, 0}写成了{2, -1, 0, 1}腿就跑到了落点那一侧永远判不到真正的阻塞点。炮的问题多半是循环条件写成了while (c ! toCol r ! toRow)或者在末尾多判了一格把目标位置的子也算进了between。解决给规则层补一组最小回归用例不要靠肉眼比对。二十来行代码就能覆盖八方向马腿和炮的三种中间子数改完规则跑一遍比盯着屏幕下十盘棋快得多。// test_rule.cpp —— 规则层的最小回归改完 board.cpp 先跑这个 static void TestHorseLeg() { Board b; b.Reset(); // 在 (4,4) 放一匹红马正上方 (4,3) 放一个红兵堵住上方向的腿 b.Set(4, 4, MakePiece(RED, HORSE)); b.Set(4, 3, MakePiece(RED, PAWN)); // 目标是 (5,2)走右上方向该方向的腿在 (5,3)没被堵应当合法 assert(IsLegalMove(b, Move{ 4, 4, 5, 2 })); // 目标是 (3,2)走左上方向腿也在 (4,3)被堵应当非法 assert(!IsLegalMove(b, Move{ 4, 4, 3, 2 })); }5.4 吃掉的棋子视觉上还留在棋盘现象走子逻辑没问题被吃的子也确实从数组里清掉了但屏幕上那枚棋子还在原地直到下一步棋才消失。原因DoMove之后忘了InvalidateRect或者InvalidateRect传的矩形只覆盖了起点和终点两格被吃子所在的那一格恰好被画了两次——一次是旧内容留下的一次是新的空。更隐蔽的一种是维护了一份独立的“绘制棋子列表”和棋盘数组不同步。解决坚持单一数据源。渲染层每帧从board数组全量重画绝不缓存额外的棋子位置。如果确实要优化缓存网格线这类静态图元棋子永远实时遍历。5.5 高分屏笔记本上棋盘整体偏移、字也发虚现象在 125% 或 150% 缩放的屏幕上跑棋盘左上角偏出去一截棋子字边缘模糊。原因程序没声明 DPI 感知系统把整个窗口当成位图做了一次拉伸你按 96 DPI 算出来的kCell 60被拉成了 75 像素坐标全都偏了。字体模糊是位图拉伸的必然结果。解决在工程的 manifest 里声明 Per-Monitor V2 感知然后按实际 DPI 缩放尺寸。// dpi.cpp —— 按窗口所在显示器的 DPI 折算实际像素 int Scale(int base) // base 是按 96 DPI 算出来的值 { UINT dpi GetDpiForWindow(GetActiveWindow()); // Win10 1607 起可用 return MulDiv(base, dpi, 96); // 四舍五入的整数缩放 }老系统上没有GetDpiForWindow退化到GetDeviceCaps(hdc, LOGPIXELSX)也够用。缩放之后棋盘尺寸不再固定3.2 节里那个static缓存的后备位图就得在WM_DPICHANGED里销毁重建否则贴上去的还是旧尺寸的内容表现是“窗口变大了一圈内容还是原来那块”。6. 二次开发把单机对弈改成带搜索的 AI 对手这份工程最值得动手改的方向是给它加一个电脑对手。棋盘结构、走法生成、胜负判定全都现成缺的只是一个评估函数和一层搜索。我的做法是先加评估再加搜索顺序不能反——评估写错的搜索跑出来的棋比随机还难看你会误以为是搜索深度不够。评估函数从最朴素的子力价值开始车 900、马炮 400、兵 100士象 200将不给分。再加一项机动性分数己方合法着法数减去对方合法着法数乘以一个很小的权重就能明显改善“马往角里钻”这类怪癖。搜索就用负极大值 alpha-beta深度四到五层配上“先吃子后走子”的着法排序剪枝能少掉一大半分支。// ai.cpp —— 负极大值搜索骨架depth 到 0 就返回评估 int Search(Board b, int depth, int alpha, int beta) { if (depth 0) return Evaluate(b); // 叶子节点静态评估 std::vectorMove moves GenerateAll(b, b.turn); OrderMoves(b, moves); // 先排吃子着法命中率最高 int best -INF; for (Move m : moves) { if (!IsLegalMove(b, m)) continue; // 总闸门复判别在生成里省这步 b.DoMoveUnchecked(m); int score -Search(b, depth - 1, -beta, -alpha); b.UndoUnchecked(); if (score best) best score; if (best alpha) alpha best; if (alpha beta) break; // 剪枝 } return best; }这里有个性能红线必须提前处理4.3 节的IsLegalMove内部复制了整个Board。搜索树里每一层每个节点都复制一次深度 5 的时候光本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑