Windows SDK开发中国象棋:从Win32绘图到Alpha-Beta剪枝实战 简介Windows SDK 象棋完整开发包面向具备基础 C/C 语法、希望快速上手 Win32 窗口程序与棋类游戏逻辑的开发者。整个工程麻雀虽小五脏俱全资源共 38 个文件压缩包仅 24KB包含 cpp/h 源码、ico/cur 图标光标、rc 资源脚本以及 dsw/dsp 工程文件代码量精简适合直接打开编译学习。项目以棋盘绘制、鼠标拖拽、着法合法性判断、将军检测等核心模块为主线通过 GDI 完成图形界面演示了窗口创建、消息循环与鼠标事件处理可帮助读者将 Windows 消息机制、资源管理和基础算法串联起来同时配套 readme 与说明文档便于理清工程结构。代码划分清晰MantisChessDraw、MantisChessThink 等文件分别处理界面与思考逻辑适合作为课程设计或入门图形编程的参考。若进一步扩展可在现有算法基础上加入 Minimax 或 Alpha-Beta 剪枝实现简单 AI也可用多线程分离交互与思考。目前已有 123 人学习对希望从零搭建一个可运行象棋程序的开发者而言这套源码比零散教程更直接、更完整。1. 为什么还有人在用 Windows SDK 写象棋一个窗口背后是整套 Windows 编程基础如果只用一个项目检验 Windows SDK 学得怎么样我会选象棋。windows sdk 象棋这个组合本质上是把 Win32 里最常碰到的几件事一次性串起来窗口类注册、消息循环、GDI 绘图、鼠标交互、基础的数据结构与递归搜索。它不像业务管理系统那样只有控件和按钮也不像图形学项目那样依赖现成框架而是让开发者亲手把棋盘格画出来、把走棋规则写清楚、把“电脑反手一步”变成代码。适合刚学完 C 语言、想进入原生 Windows 开发的从业者也适合做课程设计或内部小工具的人。真正上手后会发现难点往往不在 AI而在消息循环和绘图之间那些容易被忽略的细节。后面讲的做法都是这版题目下最常走的落地姿势。2. 用 Win32 窗口和 GDI 把棋盘先画出来骨架、坐标与双缓冲2.1 从 WinMain 到消息循环SDK 程序的基本骨架写任何 Windows SDK 程序第一步都是窗口类注册加消息循环。很多人习惯直接抄一份模板下来但象棋程序对窗口的特殊要求不多模板里每个参数是什么意思还是需要知道否则后面画面不刷新、消息收不到时根本没法排查。常见做法是写一个相对完整的 Win32 应用骨架窗口类用 WNDCLASSW窗口过程统一处理消息。代码大致如下#include windows.h LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_PAINT: { PAINTSTRUCT ps; HDC hdc BeginPaint(hWnd, ps); // 在这里画棋盘和棋子 EndPaint(hWnd, ps); return 0; } case WM_DESTROY: PostQuitMessage(0); return 0; } return DefWindowProc(hWnd, msg, wParam, lParam); } int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow) { WNDCLASSW wc {0}; wc.lpfnWndProc WndProc; wc.hInstance hInstance; wc.hbrBackground (HBRUSH)GetStockObject(WHITE_BRUSH); wc.lpszClassName LChessWindowClass; RegisterClassW(wc); HWND hWnd CreateWindowExW(0, LChessWindowClass, LSDK 象棋, WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 640, 480, NULL, NULL, hInstance, NULL); ShowWindow(hWnd, nCmdShow); MSG msg; while (GetMessageW(msg, NULL, 0, 0)) { TranslateMessage(msg); DispatchMessageW(msg); } return (int)msg.wParam; }这段代码里有两个地方最容易出问题。第一wc.lpfnWndProc必须指向你自定义的窗口过程否则所有消息都会走默认处理画什么都没用。第二wc.lpszClassName和CreateWindowExW的第一个窗口类名参数必须完全一致字符串都写错一个字符RegisterClassW虽然能成功但创建窗口时会直接返回空句柄。消息循环用GetMessageW而不是GetMessage是为了让程序在 Unicode 下工作界面文字、棋子汉字都能正常显示。GetMessageW返回 0 时代表收到WM_QUIT循环自然退出。TranslateMessage负责把键盘消息转成字符消息DispatchMessageW再把消息交给窗口过程。象棋程序主要用鼠标键盘部分不太依赖但这两行加上是稳妥习惯。这里还要注意一点wc.hbrBackground决定窗口背景画刷。如果你做了双缓冲绘制这里用GetStockObject(WHITE_BRUSH)没问题但后面如果发现闪屏可以在窗口过程中单独处理WM_ERASEBKGND。这是第 2.3 节要展开的话题。2.2 棋盘坐标计算10 行 9 列的网格到底怎么画中国象棋棋盘是 10 行 9 列对应数组board[10][9]。画棋盘时首先要定三个布局参数左边距marginX、上边距marginY、格子间距cell。常见取值是边距 40 像素、格子间距 60 像素这样一颗棋子的半径大约 26 像素窗口 640x480 足够放下。#define MARGIN_X 40 #define MARGIN_Y 40 #define CELL 60 #define RADIUS 26 void DrawBoard(HDC hdc) { HGDIOBJ oldPen SelectObject(hdc, CreatePen(PS_SOLID, 2, RGB(80, 40, 20))); // 画横线10 行每行从左边距到左边距8*CELL for (int row 0; row 10; row) { MoveToEx(hdc, MARGIN_X, MARGIN_Y row * CELL, NULL); LineTo(hdc, MARGIN_X 8 * CELL, MARGIN_Y row * CELL); } // 画竖线9 列注意中间没有竖线贯穿这里先画左右两半 for (int col 0; col 9; col) { MoveToEx(hdc, MARGIN_X col * CELL, MARGIN_Y, NULL); LineTo(hdc, MARGIN_X col * CELL, MARGIN_Y 9 * CELL); } // 删除画笔对象释放 GDI 资源 DeleteObject(SelectObject(hdc, oldPen)); }严格的中国象棋棋盘中间“楚河汉界”区域左右竖线是断开的画面中间也不能画贯穿线。上面这段代码为了显示原理只画了贯通竖线实际落地时建议按区域分段// 左半部分竖线从第0列到第4列画到第9行 for (int col 0; col 4; col) { MoveToEx(hdc, MARGIN_X col * CELL, MARGIN_Y, NULL); LineTo(hdc, MARGIN_X col * CELL, MARGIN_Y 9 * CELL); } // 右半部分竖线从第5列到第8列画到第9行 for (int col 5; col 8; col) { MoveToEx(hdc, MARGIN_X col * CELL, MARGIN_Y, NULL); LineTo(hdc, MARGIN_X col * CELL, MARGIN_Y 9 * CELL); }炮位和兵位的斜线是纯装饰初期可以省略。真正影响交互的是“棋子中心点一定落在交叉点上”所以坐标换算只需要MARGIN_X col * CELL和MARGIN_Y row * CELL这一个公式。后面鼠标点击时还会用到它。棋子绘制用 GDI 的Ellipse加汉字是成本最低的方案不需要准备图片资源。常见做法是先画一个圆作为底再在圆中心写中文字。void DrawPiece(HDC hdc, int row, int col, wchar_t name, COLORREF color) { int cx MARGIN_X col * CELL; int cy MARGIN_Y row * CELL; HBRUSH brush CreateSolidBrush(RGB(255, 228, 181)); HBRUSH oldBrush (HBRUSH)SelectObject(hdc, brush); Ellipse(hdc, cx - RADIUS, cy - RADIUS, cx RADIUS, cy RADIUS); SelectObject(hdc, oldBrush); DeleteObject(brush); SetBkMode(hdc, TRANSPARENT); SetTextColor(hdc, color); RECT rect {cx - RADIUS, cy - RADIUS, cx RADIUS, cy RADIUS}; DrawTextW(hdc, name, 1, rect, DT_CENTER | DT_VCENTER | DT_SINGLELINE); }DrawTextW的字号由当前字体决定如果不满意可以CreateFontW指定高度常见参数是-20、-24之类负号表示按字符高度而非字符宽度。这个绘制函数只负责画单个棋子整个棋盘的刷新逻辑放在WM_PAINT里统一做。2.3 双缓冲消除闪烁的固定套路SDK 象棋在早期版本里经常有一个观感问题每次落子或鼠标拖动棋子时整个窗口闪得厉害。原因是WM_PAINT里直接画图时系统会先擦掉旧内容再用新内容填充中间有一小段窗口暴露给用户视觉上就是闪烁。双缓冲是所有 GDI 绘图程序通用的解法。先在内存里建一块和窗口一样大的画布把所有内容画上去最后一次性复制到窗口。代码框架如下void DrawScene(HWND hWnd, HDC hdc) { RECT rc; GetClientRect(hWnd, rc); HDC memDC CreateCompatibleDC(hdc); HBITMAP memBmp CreateCompatibleBitmap(hdc, rc.right, rc.bottom); HGDIOBJ oldBmp SelectObject(memDC, memBmp); // 铺背景色 FillRect(memDC, rc, (HBRUSH)GetStockObject(WHITE_BRUSH)); // 画棋盘和所有棋子 DrawBoard(memDC); DrawAllPieces(memDC); // 一次性复制到窗口 BitBlt(hdc, 0, 0, rc.right, rc.bottom, memDC, 0, 0, SRCCOPY); SelectObject(memDC, oldBmp); DeleteObject(memBmp); DeleteDC(memDC); }在WM_PAINT里调用DrawScene即可case WM_PAINT: { PAINTSTRUCT ps; HDC hdc BeginPaint(hWnd, ps); DrawScene(hWnd, hdc); EndPaint(hWnd, ps); return 0; } case WM_ERASEBKGND: // 背景由双缓冲内容覆盖这里直接返回 TRUE 减少一次擦除 return 1;WM_ERASEBKGND返回 1 是很多人会漏掉的细节。窗口默认用wc.hbrBackground擦背景如果在DrawScene里已经铺了背景系统再擦一次就会白闪。处理掉之后闪屏至少改善一半。3. 从点击到规则鼠标交互、坐标换算与走棋合法性3.1 鼠标拾取棋子客户区坐标与交叉点容差窗口里的鼠标消息用WM_LBUTTONDOWN接收。lParam的低 16 位是鼠标的 x 坐标高 16 位是 y 坐标。注意这里拿到的已经是客户区坐标不需要再调用GetCursorPos或ScreenToClient换算。很多初学者在这里翻车直接用GetCursorPos拿到屏幕坐标结果点棋盘左上角棋子选中位置和右下角偏了很远。case WM_LBUTTONDOWN: { int x GET_X_LPARAM(lParam); int y GET_Y_LPARAM(lParam); int col (x - MARGIN_X CELL / 2) / CELL; int row (y - MARGIN_Y CELL / 2) / CELL; if (row 0 || row 9 || col 0 || col 8) { return 0; } // 判断点击位置是否离交叉点足够近 int cx MARGIN_X col * CELL; int cy MARGIN_Y row * CELL; if (abs(x - cx) CELL * 0.4 || abs(y - cy) CELL * 0.4) { return 0; } // 此时 row、col 就是要操作的行列 return 0; }GET_X_LPARAM在windowsx.h里定义建议引入这个头文件否则手动移位容易写错。坐标换算用(x - MARGIN_X CELL / 2) / CELL本质是“先减左边距再四舍五入到最近的格子”。直接除以CELL会有偏差比如点击第 0 列和第 1 列交界处时可能被算到相邻格。容差判断是为了避免玩家点到空白区域也能选中棋子。CELL * 0.4约等于 24 像素刚好比棋子半径 26 像素略小符合直觉。这个数值可以根据实际窗口大小调整窗口缩小时容差也应当跟着缩放。3.2 规则判定先有棋盘模型再有界面很多人在开始写 SDK 象棋时会把绘图和规则混在一起比如在DrawPiece里判断棋子是红方还是黑方在鼠标消息里直接写走子逻辑。这种做法初期能跑但一旦加 AI 和将军检测代码会变得很难维护。更合理的结构是定义独立棋盘数组#define EMPTY 0 #define RED 1 #define BLACK 2 // 棋子类型从 3 开始 #define KING 3 #define ROOK 4 #define HORSE 5 #define CANNON 6 #define ELEPHANT 7 #define GUARD 8 #define PAWN 9 // board[row][col] 的高位是阵营低位是类型 static int board[10][9];一个走法可以用结构体表示typedef struct { int fromRow, fromCol; int toRow, toCol; } Move;基本合法性校验可以简化成两步先判断目标位置是否被本方棋子占据再根据棋子类型走对应规则。int IsSameSide(int piece, int side) { if (piece EMPTY) return 0; return (piece RED piece KING) side; // 示意实际用高位判断 } int IsLegalMove(int board[10][9], int side, Move mv) { int fromPiece board[mv.fromRow][mv.fromCol]; int toPiece board[mv.toRow][mv.toCol]; if (fromPiece EMPTY) return 0; if (IsSameSide(fromPiece, side) 0) return 0; if (IsSameSide(toPiece, side)) return 0; // 不能吃自己的棋子 // 按 fromPiece 的类型分发到具体走法函数 switch (fromPiece) { case ROOK: return LegalForRook(board, mv); case HORSE: return LegalForHorse(board, mv); case CANNON: return LegalForCannon(board, mv); // 其余类型类似 } return 0; }把规则都写成独立函数后面 AI 做走法生成时可以直接调用同一套合法性判断避免出现“界面能走的棋AI 不能走”的诡异问题。这也是整个项目里最值得提前设计的一部分。3.3 将军检测让“不能送将”变成一条通用规则很多初学者写完基本走法后会发现红车可以直接走到黑将旁边吃子或者走完一步之后己方老将被对面车盯着也没事。这背后缺的是“将军检测”。将军检测应该做成通用函数不只用于判断胜负还要在每一步落子后检查“走后自己是否被将军”不允许送将。int IsSquareAttacked(int board[10][9], int row, int col, int bySide) { // 遍历 bySide 所有棋子看它们是否能走到 (row, col) for (int r 0; r 10; r) { for (int c 0; c 9; c) { int piece board[r][c]; if (piece EMPTY || 判断阵营 ! bySide) continue; Move mv {r, c, row, col}; // 这里不走真正的棋盘只判断走法规则 if (IsLegalMove(board, bySide, mv)) return 1; } } return 0; }判断某方是否被将军只要找到该方将帅的位置调用IsSquareAttacked看是否被对方攻击到即可int IsInCheck(int board[10][9], int side) { int kingRow, kingCol; FindKing(board, side, kingRow, kingCol); return IsSquareAttacked(board, kingRow, kingCol, Opposite(side)); }还要处理将帅照面两方将帅中间没有其他棋子时不能直接照面。常见做法是在IsLegalMove里对帅/将的移动单独加一条判断移动后如果和对方将帅位于同一列且中间无子就非法。这个规则不写AI 很可能走出明显离谱的棋。有了将军检测之后落子流程变成产生走法 → 走第一步 → 保存局面 → 用IsInCheck检查 → 如果己方仍然被将军则该走法非法 → 恢复局面。整条链路是在第 4 章 AI 走法生成里反复用到的。4. 给 SDK 象棋配上电脑对手从走法生成到 alpha-beta 剪枝4.1 走法生成让电脑第一步有棋可走AI 的第一步不是搜索是走法生成。搜索引擎再快没有合法走法列表也白搭。走法生成要做的就是遍历棋盘上当前所有的己方棋子对每个棋子枚举它能走到的所有位置再交给“走后是否送将”这个过滤器过滤一遍。void GenAllMoves(int board[10][9], int side, Move moves[], int* count) { *count 0; for (int r 0; r 10; r) { for (int c 0; c 9; c) { int piece board[r][c]; if (IsSameSide(piece, side) 0) continue; Move pieceMoves[64]; int n 0; // 根据棋子类型生成各自走法 switch (piece) { case ROOK: GenRookMoves(board, r, c, pieceMoves, n); break; case HORSE: GenHorseMoves(board, r, c, pieceMoves, n); break; case CANNON: GenCannonMoves(board, r, c, pieceMoves, n); break; // 其他棋子类似 } for (int i 0; i n; i) { if (!WillSelfBeInCheck(board, side, pieceMoves[i])) { moves[(*count)] pieceMoves[i]; } } } } }WillSelfBeInCheck实现方式是直接执行走法调用IsInCheck判断再执行反向走法恢复。这里要求MakeMove和UnmakeMove成对出现。如果只做界面落子不写UnmakeMove也勉强能跑但 AI 搜索要在同一块棋盘上反复尝试没有UnmakeMove就无从谈起。走法生成里最容易遗漏的是蹩马腿和塞象眼。马走日字蹩马腿判断的是“马起点和终点连线方向上的相邻位置是否有棋子”。象走田字塞象眼判断的是“象起点和终点之间的田字中心是否有棋子”。这两个规则看似简单但写错后电脑会走出很多人类一眼就看出来的废棋。4.2 评估函数AI 的“手感”全靠这张表AI 落子不靠死记硬背而是搜索加评估。评估函数给每个局面打一个分数正数代表红方占优负数代表黑方占优。最简单的评估是棋子价值表比如车值 500、炮值 300、马值 300、兵值 100帅值一个非常大的数。但只看子力值AI 会显得很“笨”因为它不知道车在底线和车在河沿的价值差别。int Evaluate(int board[10][9]) { int score 0; for (int r 0; r 10; r) { for (int c 0; c 9; c) { int piece board[r][c]; if (piece EMPTY) continue; int base PieceValue(piece); int position PositionValue(piece, r, c); if (IsRed(piece)) { score base position; } else { score - base position; } } } return score; }位置价值表是 AI 水平的重要来源。例如“兵过河后价值上升”“马跳到河沿控制力强”“炮在中路更活跃”这些都能通过一张 10x9 的位置表体现出来。初期可以用经验值把红方表格写在数组里黑方做镜像即可。评估函数要注意的单位统一。不要一个棋子给几百另一个给个位数这样搜索时数值波动太大剪枝效果差。我一般把棋子价值控制在 100 到 1000 的范围内位置表控制在 10 到 50 的范围这样评估结果既稳定又容易调试。4.3 Alpha-Beta深度、剪枝顺序和线程打断走法生成评估函数都有了就可以写搜索了。朴素 minmax 搜索在象棋这种平均走法数三四十的棋类里完全不能看四层搜索就要遍历几十万节点。alpha-beta 剪枝能在不改变结果的前提下砍掉大量分支是常见象棋引擎的核心算法。int AlphaBeta(int depth, int alpha, int beta, int side) { if (depth 0) return Evaluate(); Move moves[MAX_MOVES]; int count 0; GenAllMoves(board, side, moves, count); if (count 0) { if (IsInCheck(board, side)) return -INF depth; // 被将死 return 0; // 无棋可走判和 } SortMoves(moves, count); // 吃子、将军走法靠前 for (int i 0; i count; i) { MakeMove(moves[i]); int score -AlphaBeta(depth - 1, -beta, -alpha, Opposite(side)); UnmakeMove(moves[i]); if (score beta) return beta; if (score alpha) alpha score; } return alpha; }参数里depth是搜索深度alpha是当前搜索窗口下界beta是上界。递归调用时用-beta, -alpha并取负这是常规写法目的是把视角从“当前方要最大化”切换成“对方要最大化”。SortMoves很重要如果每次都是按棋盘顺序枚举剪枝效率会低很多常见做法是让吃子、将军、将死威胁这些大棋优先尝试。搜索深度开局建议从 3 开始。深度 3 已经能看到一两步棋的吃子交换够日常对局。优化后可以到 5但要注意界面卡顿。alpha-beta 是同步递归调用如果直接放在 UI 线程里搜索期间窗口完全无法响应鼠标拖动窗口会进入“无响应”状态。这个问题放到第 5 章专门说。5. Windows SDK 象棋开发避坑消息卡死、绘图闪烁与坐标翻车的 5 个典型案例5.1 现象窗口创建后一片白绘图代码“消失”不少人的第一版程序运行后能看到窗口但棋盘和棋子都没画出来或者窗口被遮挡再恢复后内容全部消失。原因绘图代码写在WM_PAINT外面或者WM_PAINT里没有调用BeginPaint/EndPaint。SDK 的绘制机制是“按需重画”窗口内容丢失时系统发WM_PAINT你只在WM_PAINT里画的东西才能被正确刷新。如果一开始在WinMain里直接拿GetDC画画窗口一被遮挡内容就没了。解决把所有绘制逻辑放到WndProc的WM_PAINT分支里用BeginPaint拿设备上下文画完立刻EndPaint。这两次调用必须成对否则窗口可能收不到后续绘制消息。5.2 现象鼠标点击选不中棋子边缘总选错点击交叉点附近时选中了隔壁格子或者点击棋子边缘没反应。原因坐标换算写错了最常见的是直接用GetCursorPos拿屏幕坐标或者把lParam里的坐标当成像素但窗口有非客户区标题栏、边框偏置。另一个常见原因是没做距离容差点击位置偏离交叉点 20 像素也判定为有效。解决一律从WM_LBUTTONDOWN的lParam取客户区坐标换算后判断与交叉点距离超过CELL * 0.4就忽略。高 DPI 环境下要在程序启动时声明 DPI 感知否则坐标会被系统缩放产生偏移。5.3 现象每次落子棋盘狂闪甚至留下棋子残影开局界面正常一旦走一步棋或者鼠标扫过棋盘整个窗口闪得厉害。原因没有双缓冲。直接在WM_PAINT里画棋盘时系统先擦旧背景再画新内容擦和画之间的空隙暴露了白色或灰色背景。残影则来自没有正确调用InvalidateRect刷新局部区域旧位置的棋子没擦干净。解决按第 2.3 节做内存 DC 双缓冲同时处理WM_ERASEBKGND返回 1阻断系统默认擦除。局部刷新时用InvalidateRect传入目标矩形而不是每次都刷整个窗口。5.4 现象电脑思考时窗口“无响应”拖不动玩家走完一步程序开始搜索窗口立刻卡住标题栏出现“未响应”有时等几秒才恢复。原因搜索递归占用了 UI 线程消息循环没有机会处理WM_PAINT、WM_MOUSEMOVE等消息。窗口系统会认为程序挂了。解决最简单的方案是在搜索循环里周期性地用PeekMessage抽取并派发消息让窗口有机会重绘。更好的方案是把搜索放到工作线程里搜索结束后用PostMessage发自定义消息通知主线程刷新界面。线程方案需要处理好棋盘数据的互斥否则一边搜索一边点鼠标会读到中间状态。5.5 现象AI 下几盘后棋子越数越少或者局面恢复错误搜索时能走出合法棋但走完几步后局面明显不对比如某个棋子凭空消失或者返回前一步时棋盘对不上。原因MakeMove吃掉的子没有保存UnmakeMove只恢复了移动位置没有恢复被吃掉的棋子。搜索过程中尝试大量走法每一条都改动棋盘常量恢复不完整就会把数组污染。解决MakeMove返回被吃掉的棋子UnmakeMove必须把被吃子恢复到原位置同时恢复移动起点和终点。写成成对函数并在GenAllMoves的合法性过滤里只用它不要手动改棋盘数组。6. 验证与进阶让 SDK 象棋从“能走”到“能下”的检查清单和升级路径6.1 用自对弈做回归测试让两个电脑无限下棋直到出错想验证规则和 AI 是否稳定靠人下棋是不够的。常见做法是让红方和黑方都走随机浮动的简单评估函数自动对弈几百步观察有没有崩溃、非法走法、棋子丢失、将军没被处理等情况。测试时我会在关键位置打印局面哈希每走一步记录一次。如果某一步之后红方棋子总数变了说明MakeMove/UnmakeMove有问题。能连续自动下完 60 到 100 步不报错基本可以进入单人模式。6.2 进阶方向悔棋、历史列表、线程化搜索与协议对接规则和 AI 稳定后下一步可以做的功能依次是功能需要的改动优先级悔棋记录走法历史栈出栈时反向恢复局面刷新 UI高自动判和/判负无合法走法时根据是否被将军判断胜负高搜索线程化把 alpha-beta 放到后台线程搜索期间允许界面响应高残局库用哈希值和预置残局数据辅助搜索低对弈协议把走法输出成标准文本方便对接其他引擎复盘低悔棋的内部实现几乎就是UnmakeMove的封装但要注意玩家悔棋后 AI 也要跟着悔一步最好把“红黑双方走法”作为一对记录。搜索线程化前先确认棋盘数据是否会被主线程同时访问常见做法是搜索期间禁止鼠标输入搜索结束再恢复。最后说一个我自己的教训。早期版本我把搜索深度调到 5结果窗口假死得厉害以为是算法效率问题后来才发现是搜索没给消息循环喘息的机会。从那以后我的习惯一直是“先保证界面在长任务里能响应再谈 AI 深度”。现在这版思路也是一样棋盘画好、规则跑通、消息不丢再往搜索和评估上花时间。希望帮到你。本文还有配套的精品资源点击获取