简介: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 = L"ChessWindowClass"; RegisterClassW(&wc); HWND hWnd = CreateWindowExW(0, L"ChessWindowClass", L"SDK 象棋", 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 深度”。现在这版思路也是一样:棋盘画好、规则跑通、消息不丢,再往搜索和评估上花时间。
希望帮到你。
本文还有配套的精品资源,点击获取