☰
用Win32 GDI写中国象棋:从窗口消息到Alpha-Beta AI完整实战
2026/10/28 20:29:34 网站建设 项目流程

简介:一份基于Windows SDK开发的象棋小游戏完整源码包,面向初步接触Windows编程或希望借助具体游戏项目吃透消息循环、GDI绘图等基础机制的开发者。压缩包内共38个文件,整包仅24KB,以C++源文件、头文件、图标和光标资源为主,并附有工程文件与说明文档,可直接在Visual Studio环境中打开编译,也便于移植到其他Win32工程。目前已获123人学习,读者可对照代码理清棋盘绘制、鼠标拖放、落子判定与走棋规则的完整实现流程。项目虽小但模块完整:棋盘绘制、棋子渲染、走棋思考、头文件声明等各有独立代码文件,还附带readme与文本说明,方便快速定位函数入口。在此基础上,可进一步替换或扩展AI决策逻辑,非常适合作为深入理解Win32窗口编程与经典API调用的自学范例。

1. Windows SDK 象棋:为什么说窗口消息是这盘棋的主线

Windows SDK 写中国象棋,乍看是上个世纪的练手项目,但完整跑通之后,你对 Win32 消息循环、GDI 绘制、像素坐标换算这些基础能力的理解会上一整个台阶。这个项目不需要任何第三方库,一个源文件就能把窗口创建、棋盘绘制、鼠标行棋、AI 落子全部装下,编译出来只有几十 KB。它最值钱的部分不是棋盘本身,而是「棋盘状态 → 像素坐标 → 鼠标点击 → 走法校验 → 重绘」这条链路被完整闭环了一次。适合三类人:课程设计要交桌面程序的学生、桌面开发岗面试前想补 Win32 底子的候选人、日常维护老项目里 Win32/MFC 界面的在职开发。下面按我实际拆解这个项目的顺序讲:先绘制,再规则,再 AI,最后是避坑。

2. 棋盘绘制与坐标体系:GDI 换算、双缓冲一次做对

绘制是整盘棋的地基,也是新手最先翻车的地方。棋盘看起来只是一张图,实际由网格线、九宫斜线、河界文字、棋子、选中框五层叠加而成,每一层都吃 GDI 对象和坐标换算。这一章把数据结构和绘制代码一次讲透。

2.1 棋盘网格:一张数组和两套坐标

先定义棋盘数据。我习惯用 10×9 的二维数组,行优先存储:

// grid[row][col]:0 表示空 // 红方 1-7:帅 仕 相 马 车 炮 兵 // 黑方 11-17:将 士 象 马 车 炮 卒 int grid[10][9]; // 棋盘的像素参数,统一用宏 #define MARGIN_X 40 #define MARGIN_Y 40 #define CELL_SIZE 60

为什么把黑方编成 11-17 而不是 1-7?因为这个编号方案里,p % 10直接拿到棋子类型,p > 10直接判断黑方,一个整型同时携带颜色和种类。后面写 IsLegalMove、IsInCheck 和 AI 评估函数时,省掉大量大小写转换和查表代码。这一点经验是从重构里换来的:第一版我用了正负号区分红黑,结果每个函数里都要写一遍p < 0的判断,又乱又容易错。

棋盘上每个交叉点叫逻辑坐标(file, rank),横 0-8、纵 0-9;屏幕上的点叫像素坐标。两者换算关系固定如下,全项目只保留这两组公式:

// 逻辑坐标 -> 像素坐标(棋子中心点) int px = MARGIN_X + file * CELL_SIZE; int py = MARGIN_Y + rank * CELL_SIZE; // 像素坐标 -> 逻辑坐标(带四舍五入) int file = (px - MARGIN_X + CELL_SIZE / 2) / CELL_SIZE; int rank = (py - MARGIN_Y + CELL_SIZE / 2) / CELL_SIZE;

像素转逻辑坐标时,先加半个格子再整除,等于做四舍五入。这个细节直接决定你点的位置准不准,后面避坑章会专门展开。

绘制网格线的代码长这样:

void DrawBoard(HDC hdc) { HPEN hPen = CreatePen(PS_SOLID, 1, RGB(80, 50, 20)); HPEN hOld = (HPEN)SelectObject(hdc, hPen); // 10 条横线,整条画 for (int r = 0; r < 10; r++) { MoveToEx(hdc, MARGIN_X, MARGIN_Y + r * CELL_SIZE, NULL); LineTo(hdc, MARGIN_X + 8 * CELL_SIZE, MARGIN_Y + r * CELL_SIZE); } // 9 条竖线,上下两段画,中间留出河界 for (int f = 0; f < 9; f++) { MoveToEx(hdc, MARGIN_X + f * CELL_SIZE, MARGIN_Y, NULL); LineTo(hdc, MARGIN_X + f * CELL_SIZE, MARGIN_Y + 4 * CELL_SIZE); MoveToEx(hdc, MARGIN_X + f * CELL_SIZE, MARGIN_Y + 5 * CELL_SIZE, NULL); LineTo(hdc, MARGIN_X + f * CELL_SIZE, MARGIN_Y + 9 * CELL_SIZE); } // 九宫斜线:上方黑方九宫、下方红方九宫 MoveToEx(hdc, MARGIN_X + 3 * CELL_SIZE, MARGIN_Y, NULL); LineTo(hdc, MARGIN_X + 5 * CELL_SIZE, MARGIN_Y + 2 * CELL_SIZE); MoveToEx(hdc, MARGIN_X + 5 * CELL_SIZE, MARGIN_Y, NULL); LineTo(hdc, MARGIN_X + 3 * CELL_SIZE, MARGIN_Y + 2 * CELL_SIZE); MoveToEx(hdc, MARGIN_X + 3 * CELL_SIZE, MARGIN_Y + 7 * CELL_SIZE, NULL); LineTo(hdc, MARGIN_X + 5 * CELL_SIZE, MARGIN_Y + 9 * CELL_SIZE); MoveToEx(hdc, MARGIN_X + 5 * CELL_SIZE, MARGIN_Y + 7 * CELL_SIZE, NULL); LineTo(hdc, MARGIN_X + 3 * CELL_SIZE, MARGIN_Y + 9 * CELL_SIZE); // 河界文字 SetBkMode(hdc, TRANSPARENT); SetTextColor(hdc, RGB(80, 50, 20)); TextOut(hdc, MARGIN_X + 2 * CELL_SIZE, MARGIN_Y + 4 * CELL_SIZE + 6, TEXT("楚 河"), 4); TextOut(hdc, MARGIN_X + 5 * CELL_SIZE, MARGIN_Y + 4 * CELL_SIZE + 6, TEXT("汉 界"), 4); SelectObject(hdc, hOld); DeleteObject(hPen); }

两个容易被忽略的点。第一,竖线不能画完整条再用背景色覆盖河界,那样覆盖区域和背景色有细微差别,窗口一刷新就露馅;直接分两段画最干净。第二,CreatePen 创建的是进程级 GDI 对象,函数结束必须 DeleteObject,这里不用我多说,避坑章有完整事故记录。

工程形态上,这个项目就是单文件加 WinMain 加一个 WndProc,Windows SDK 自带的 user32/gdi32 足够,编译命令取决于你本地的工具链:

# MinGW gcc -mwindows chess.c -o chess.exe # MSVC cl chess.c user32.lib gdi32.lib

2.2 棋子绘制:笔刷、字体与选中框

棋子绘制比棋盘更讲究顺序。每个棋子是一个椭圆底、一个汉字、一张选中框三层叠加,像素顺序错了字形就被盖掉:

const char* kName[18] = { "", "帅", "仕", "相", "马", "车", "炮", "兵", "", "", "", "将", "士", "象", "马", "车", "炮", "卒" }; void DrawPiece(HDC hdc, int file, int rank) { int p = grid[rank][file]; if (p == 0) return; int cx = MARGIN_X + file * CELL_SIZE; int cy = MARGIN_Y + rank * CELL_SIZE; int r = 26; // 棋子半径 // 第一层:椭圆底,红方浅色、黑方深色 HBRUSH hBrush = CreateSolidBrush(p > 10 ? RGB(52, 52, 52) : RGB(238, 220, 180)); HBRUSH hOldBrush = (HBRUSH)SelectObject(hdc, hBrush); Ellipse(hdc, cx - r, cy - r, cx + r, cy + r); SelectObject(hdc, hOldBrush); DeleteObject(hBrush); // 第二层:汉字,用 GDI 字体而不是位图,避免缩放模糊 HFONT hFont = CreateFont(28, 12, 0, 0, FW_BOLD, FALSE, FALSE, FALSE, GB2312_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, DEFAULT_QUALITY, DEFAULT_PITCH, TEXT("SimSun")); HFONT hOldFont = (HFONT)SelectObject(hdc, hFont); SetBkMode(hdc, TRANSPARENT); SetTextColor(hdc, p > 10 ? RGB(245, 235, 220) : RGB(170, 30, 30)); RECT rc = { cx - r, cy - r, cx + r, cy + r }; DrawText(hdc, kName[p], -1, &rc, DT_CENTER | DT_VCENTER | DT_SINGLELINE); SelectObject(hdc, hOldFont); DeleteObject(hFont); // 第三层:选中框,只有当前选中的棋子才画 if (selectedFile == file && selectedRank == rank) { HPEN hSelPen = CreatePen(PS_SOLID, 3, RGB(200, 30, 30)); HPEN hOldPen = (HPEN)SelectObject(hdc, hSelPen); HBRUSH hEmpty = (HBRUSH)SelectObject(hdc, GetStockObject(NULL_BRUSH)); Rectangle(hdc, cx - r + 2, cy - r + 2, cx + r - 2, cy + r - 2); SelectObject(hdc, hEmpty); SelectObject(hdc, hOldPen); DeleteObject(hSelPen); } }

这里有个细节值得记:选中框的笔刷用的是 GetStockObject(NULL_BRUSH),它是系统库存对象,不要 DeleteObject。如果图省事 CreateSolidBrush 一个空笔刷,那就是一个泄漏点。汉字用 CreateFont 按需创建,虽然每次绘制都建删字体有点浪费,但单窗口象棋完全在可接受范围,换来的是字体和窗口缩放完全一致。

2.3 双缓冲重绘:WM_PAINT 的正确姿势

Win32 的绘制全部集中在 WM_PAINT 里,不要在消息外直接往窗口上画。所有绘制动作先画到内存 DC,再一次 BitBlt 回屏幕,这就是双缓冲:

case WM_PAINT: { PAINTSTRUCT ps; HDC hdc = BeginPaint(hwnd, &ps); // 创建内存 DC 和兼容位图 HDC hdcMem = CreateCompatibleDC(hdc); HBITMAP hbmMem = CreateCompatibleBitmap(hdc, BOARD_W, BOARD_H); HBITMAP hbmOld = (HBITMAP)SelectObject(hdcMem, hbmMem); // 全部画到内存 DC DrawBoard(hdcMem); for (int r = 0; r < 10; r++) for (int f = 0; f < 9; f++) DrawPiece(hdcMem, f, r); // 一次性拷回屏幕 BitBlt(hdc, 0, 0, BOARD_W, BOARD_H, hdcMem, 0, 0, SRCCOPY); SelectObject(hdcMem, hbmOld); DeleteObject(hbmMem); DeleteDC(hdcMem); EndPaint(hwnd, &ps); break; }

配套的还有一点:触发重绘时统一用InvalidateRect(hwnd, NULL, FALSE),最后一个参数 FALSE 表示不擦除背景。双缓冲加不擦背景,闪屏的两个来源就都堵死了。棋盘每次点击改变后只重绘必要区域也可以,但象棋棋盘就一块,全量重绘几百微秒的事,不值得为这点性能引入脏矩形复杂度。

3. 行棋逻辑与鼠标交互:从 WM_LBUTTONDOWN 到落子校验

绘制解决的是「棋子长什么样」,这一章解决「棋子能不能走」。行棋逻辑拆成三块:鼠标点击状态机、七种棋子走法校验、将军与胜负判定。三块是递进关系,缺一块对局就进行不下去。

3.1 点击状态机:先选中,再落子

象棋交互不是「点一下就落子」,而是「第一次点选中、第二次点落子」的两段式状态机。状态用两个全局变量记录:

int selectedFile = -1; int selectedRank = -1; // -1 表示当前没有选中任何棋子 void OnCellClicked(int file, int rank) { int p = grid[rank][file]; int mySide = (currentSide == BLACK); // currentSide: RED=0, BLACK=1 if (selectedFile < 0) { // 第一次点击:必须落在己方棋子上 if (p != 0 && (p > 10) == mySide) { selectedFile = file; selectedRank = rank; } } else { // 第二次点击:己方棋子则换选,否则尝试落子 if (p != 0 && (p > 10) == mySide) { selectedFile = file; selectedRank = rank; } else { if (MakeMove(selectedFile, selectedRank, file, rank)) { selectedFile = selectedRank = -1; currentSide = !currentSide; } // 非法走法:保留选中状态,让玩家重新落 } } InvalidateRect(hwnd, NULL, FALSE); }

鼠标消息进 WndProc 后,用坐标换算得到逻辑坐标再交给状态机:

case WM_LBUTTONDOWN: { int px = GET_X_LPARAM(lParam); int py = GET_Y_LPARAM(lParam); int file = (px - MARGIN_X + CELL_SIZE / 2) / CELL_SIZE; int rank = (py - MARGIN_Y + CELL_SIZE / 2) / CELL_SIZE; if (file < 0 || file > 8 || rank < 0 || rank > 9) break; OnCellClicked(file, rank); break; }

注意边界判断必须在点击函数之前做。点了棋盘外面一圈的空白区域,换算出来的 file/rank 可能落在合法区间之外,不拦截的话就会拿着越界下标访问 grid,这就是所谓的越界翻车。

3.2 七种棋子的走法校验

走法校验是整个项目最容易被改坏的地方,因为它有七套规则、还有蹩马腿、塞象眼、炮架、兵卒过河这些特殊约束。我把所有规则收敛到一个函数里:

int IsLegalMove(int fx, int fy, int tx, int ty) { if (fx == tx && fy == ty) return 0; // 原地不动 int p = grid[fy][fx]; if (p == 0) return 0; int q = grid[ty][tx]; // 目标格是己方棋子,不能走 if (q != 0 && (p > 10) == (q > 10)) return 0; int dx = tx - fx, dy = ty - fy; int type = p % 10; int black = p > 10; switch (type) { case 1: // 帅/将:九宫内直走一步 if (tx < 3 || tx > 5) return 0; if (black && (ty < 0 || ty > 2)) return 0; if (!black && (ty < 7 || ty > 9)) return 0; return abs(dx) + abs(dy) == 1; case 2: // 士/仕:九宫内斜走一步 if (abs(dx) != 1 || abs(dy) != 1) return 0; if (tx < 3 || tx > 5) return 0; if (black && (ty < 0 || ty > 2)) return 0; if (!black && (ty < 7 || ty > 9)) return 0; return 1; case 3: { // 象/相:田字走,注意塞象眼 if (abs(dx) != 2 || abs(dy) != 2) return 0; if (black && (ty < 0 || ty > 4)) return 0; // 不能过河 if (!black && (ty < 5 || ty > 9)) return 0; if (grid[fy + dy / 2][fx + dx / 2] != 0) return 0; // 塞象眼 return 1; } case 4: { // 马:日字走,蹩马腿 if (abs(dx) == 1 && abs(dy) == 2) return grid[fy][fx + (dx > 0 ? 1 : -1)] == 0; if (abs(dx) == 2 && abs(dy) == 1) return grid[fy + (dy > 0 ? 1 : -1)][fx] == 0; return 0; } case 5: // 车:直线,路径上不能有子 case 6: { // 炮:不吃子要直线无子,吃子要隔一个炮架 if (dx != 0 && dy != 0) return 0; int sx = dx ? (dx > 0 ? 1 : -1) : 0; int sy = dy ? (dy > 0 ? 1 : -1) : 0; int cx = fx + sx, cy = fy + sy, cnt = 0; while (cx != tx || cy != ty) { if (grid[cy][cx] != 0) cnt++; cx += sx; cy += sy; } if (type == 5) return cnt == 0; // 车必须无遮挡 return q == 0 ? cnt == 0 : cnt == 1; // 炮吃子要隔一个 } case 7: { // 兵/卒:过河前只能前进,过河后可横走 int forward = black ? 1 : -1; // 黑往下、红往上 if (dy == forward && dx == 0) return 1; if (black ? (fy > 4) : (fy < 5)) // 已经过河 if (abs(dx) == 1 && dy == 0) return 1; return 0; } } return 0; }

几个容易写错的地方直接说。炮的代码是车和炮共用一个路径扫描,靠cnt区分:吃子要求中间恰好一个子,不吃子要求中间零个子。「恰好一个」很容易写成「大于等于一个」,那样炮隔两子也能吃,规则就废了。兵的横走条件里,判过河用的是「当前位置」,不是目标位置,这一点用fy而不是ty。

红黑镜像也是一个经典坑。我第一次写时把红方的九宫范围写成了ty < 0 || ty > 2,结果红帅直接越过了河界跑到黑方九宫。红方在下方,九宫是 7-9 行;黑方在上方,九宫是 0-2 行,两边是镜像关系,不能只复制粘贴。

3.3 将军判定与胜负判断

走法合法不代表这步能走,还得保证走完自己的将帅不被将军。将军判定的标准写法是:找到己方将的位置,枚举对方所有棋子,看有没有任何一步能吃到将:

int IsInCheck(int side) { // 找己方将/帅 int gx = -1, gy = -1; for (int y = 0; y < 10; y++) for (int x = 0; x < 9; x++) { int p = grid[y][x]; if (p != 0 && (p > 10) == side && p % 10 == 1) { gx = x; gy = y; } } if (gx < 0) return 1; // 将都没了,直接判负 // 对方任意棋子能吃到将,就算被将军 for (int y = 0; y < 10; y++) for (int x = 0; x < 9; x++) { int p = grid[y][x]; if (p == 0 || (p > 10) == side) continue; if (IsLegalMove(x, y, gx, gy)) return 1; } // 将帅对脸:同列且中间无子,也算被将军 for (int y = 0; y < 10; y++) { int p = grid[y][gx]; if (p == 0) continue; if (p % 10 == 1 && (p > 10) != side) { int between = 0; for (int yy = gy + 1; yy < y; yy++) if (grid[yy][gx] != 0) between++; if (between == 0) return 1; } break; // 只检查同列第一个非空子 } return 0; }

这里最容易被漏掉的是「走完自己反而被将军」的情况。很多初版代码只校验走法本身合法就直接落子,结果你的车挡在将面前,对面直接照脸。正确做法是在落子前试走一步,调用 IsInCheck 检查自己,合法才真正提交。这个试走逻辑在 AI 的走法生成里也会复用。

胜负判定其实不用单独写一个复杂函数。走完一步后,如果对方被将军且没有任何合法走法,就是将死;如果对方没有合法走法但没被将军,在象棋里叫困毙,同样判负——注意这一点和国象不一样,国象逼和算和棋,象棋困毙算输。判断「没有合法走法」的最笨但最可靠的办法:枚举所有己方棋子、所有目标格,逐个试走并检查是否仍然被将军,只要有一个可行就走不了将死:

int HasAnyLegalMove(int side) { for (int fy = 0; fy < 10; fy++) for (int fx = 0; fx < 9; fx++) { int p = grid[fy][fx]; if (p == 0 || (p > 10) != side) continue; for (int ty = 0; ty < 10; ty++) for (int tx = 0; tx < 9; tx++) { if (!IsLegalMove(fx, fy, tx, ty)) continue; // 试走一步 int saved = grid[ty][tx]; grid[ty][tx] = p; grid[fy][fx] = 0; int checked = IsInCheck(side); grid[fy][fx] = p; grid[ty][tx] = saved; if (!checked) return 1; } } return 0; }

这段代码 40 行就把「将死」和「困毙」一起覆盖了,性能上每步最多试 90×90 种组合,象棋棋盘小,毫秒级返回,完全不用优化。我踩过的坑是把试走逻辑在 IsLegalMove 里藏了一份、在 AI 里又写了一份,改规则时只改了一处,结果对弈双方规则不对称,棋局走到中局开始出现对方能走我方不能走的现象,定位花了一下午。

4. 人机对战的 AI:局面评估与 Alpha-Beta 搜索落地

画好棋盘、定好规则之后,这盘棋只能两个人互相点着玩。加一个 AI 对手,项目才算完整。象棋 AI 不复杂,核心就三件事:评估局面、枚举走法、搜索择优。这一章给出一份能实际对战的最低可用实现。

4.1 局面评估:子力价值打底

评估函数回答「当前局面谁占优」。最朴素的版本就是子力价值加减,象棋各子力价值参考如下:

棋子分值
帅/将10000
车900
炮650
马520
相/象240
仕/士240
兵/卒130

帅给 10000 而不是无穷大,是为了让搜索在「被将死」和「丢个车」之间能算出明确差异,而不是溢出。实现如下:

const int kValue[8] = { 0, 10000, 240, 240, 520, 900, 650, 130 }; int Evaluate(void) { int score = 0; for (int y = 0; y < 10; y++) for (int x = 0; x < 9; x++) { int p = grid[y][x]; if (p == 0) continue; int v = kValue[p % 10]; // 红方为正、黑方为负,得分永远是「红减黑」 score += (p > 10) ? -v : v; } return score; }

这个评估函数有个缺陷:兵过了河才值钱,马在边角活动受限,但这些它都感知不到。想提升棋力,最常见做法是加一张位置加分表:兵过了河每进一步加 30 到 80 分,马在棋盘中央加 20 分、在边角减 20 分。位置表本质上就是把「经验」塞进数字,写起来不复杂,但属于锦上添花,先把搜索写对再调这层。

4.2 走法生成、Alpha-Beta 搜索与剪枝

AI 每走一步要回答「走哪步最好」,答案不是一步看出来的,而是往下推演若干层。先实现走法生成,把所有合法走法枚举出来:

typedef struct { int fx, fy, tx, ty; // 起点、终点 int score; // 用于排序,不参与棋局本身 } Move; int GenerateMoves(int side, Move* moves) { int count = 0; for (int fy = 0; fy < 10; fy++) for (int fx = 0; fx < 9; fx++) { int p = grid[fy][fx]; if (p == 0 || (p > 10) != side) continue; for (int ty = 0; ty < 10; ty++) for (int tx = 0; tx < 9; tx++) { if (!IsLegalMove(fx, fy, tx, ty)) continue; // 试走一步,确认自己不会被将军 int saved = grid[ty][tx]; grid[ty][tx] = p; grid[fy][fx] = 0; if (!IsInCheck(side)) { moves[count].fx = fx; moves[count].fy = fy; moves[count].tx = tx; moves[count].ty = ty; moves[count].score = 0; count++; } grid[fy][fx] = p; grid[ty][tx] = saved; } } return count; }

注意这里和 3.3 节 HasAnyLegalMove 的判断逻辑完全一致,都是先走再查将,保证生成的走法绝不会把自己送到被将军的局面。接下来是搜索主体,我用负极大值形式写 Alpha-Beta:

int AlphaBeta(int depth, int alpha, int beta, int side) { Move moves[256]; int n = GenerateMoves(side, moves); if (n == 0) { // 无子可走:被将死或困毙,立即返回负无穷,深度越小输得越快越差 return (side == RED) ? -(INF - depth * 100) : (INF - depth * 100); } if (depth == 0) return Evaluate(); for (int i = 0; i < n; i++) { int saved = grid[moves[i].ty][moves[i].tx]; grid[moves[i].ty][moves[i].tx] = grid[moves[i].fy][moves[i].fx]; grid[moves[i].fy][moves[i].fx] = 0; // 负极大值:对方视角取负 int val = -AlphaBeta(depth - 1, -beta, -alpha, !side); grid[moves[i].fy][moves[i].fx] = grid[moves[i].ty][moves[i].tx]; grid[moves[i].ty][moves[i].tx] = saved; if (val > alpha) { alpha = val; if (alpha >= beta) break; // beta 剪枝,直接放弃这一分支 } } return alpha; }

判断将死时返回-(INF - depth * 100),这里有个小技巧:depth 越小说明死得越快,值就越差。如果两种情况都返回 -INF,AI 分不清「一步被杀」和「十步被杀」,实战中会出现它走出一个很快被将死的局面还觉得一样的昏招。用 depth 加权后,AI 会优先选晚死的那条路。

4.3 搜索深度、走法排序与耗时控制

Alpha-Beta 的实际效率和走法顺序强相关。好的顺序能让剪枝提前触发,坏顺序可能让剪枝形同虚设。最简单的排序是「先走吃大子的棋」:

void SortMoves(Move* moves, int n) { for (int i = 0; i < n; i++) moves[i].score = grid[moves[i].ty][moves[i].tx] % 10; // 按 score 降序插入排序,n 通常 40-60,插入排序足够 for (int i = 1; i < n; i++) { Move key = moves[i]; int j = i - 1; while (j >= 0 && moves[j].score < key.score) { moves[j + 1] = moves[j]; j--; } moves[j + 1] = key; } }

吃帅记 10000 分,所以将军的走法永远排最前面,剪枝效率立竿见影。纯子力排序已经能让 4 层搜索秒回,够用了。

搜索深度方面,单机对战 4 层合适;3 层棋力太弱,5 层在普通笔记本上可能偶尔卡顿。我会给整局 AI 套一个时间预算,用迭代加深控制:

Move SearchBestMove(int depth, int side) { Move moves[256]; int n = GenerateMoves(side, moves); if (n == 0) return (Move){ -1, -1, -1, -1, 0 }; SortMoves(moves, n); int bestScore = -INF; Move best = moves[0]; for (int i = 0; i < n; i++) { int saved = grid[moves[i].ty][moves[i].tx]; grid[moves[i].ty][moves[i].tx] = grid[moves[i].fy][moves[i].fx]; grid[moves[i].fy][moves[i].fx] = 0; int val = -AlphaBeta(depth - 1, -INF, INF, !side); grid[moves[i].fy][moves[i].fx] = grid[moves[i].ty][moves[i].tx]; grid[moves[i].ty][moves[i].tx] = saved; if (val > bestScore) { bestScore = val; best = moves[i]; } } return best; } void AIPlay(void) { Move m = SearchBestMove(4, currentSide); if (m.fx < 0) return; // 认输 ApplyMove(m.fx, m.fy, m.tx, m.ty); currentSide = !currentSide; InvalidateRect(hwnd, NULL, FALSE); }

真正发布版本里,这个 AIPlay 不该在主线程裸奔。简单做法是丢进 WM_TIMER,用定时器驱动 AI,每 300ms 检查一次轮到谁;进阶做法是开一个工作线程搜索,结束后 PostMessage 通知主窗口。线程方案这里不展开,避坑章会讲裸奔的代价。

5. Win32 象棋避坑指南:五个常见翻车现场与处理

写完能跑的版本只算完成一半,另一半是排掉那些「一开始没毛病、跑十分钟就犯病」的问题。这五个坑我全部亲手踩过,按现象、原因、解决三段式记录,方便你对照排查。

5.1 闪屏、残影与背景泛白

现象:落子后窗口先白一下再出棋盘,拖动窗口边缘时棋盘拖影,棋盘区域偶发黑块。

原因:WM_PAINT 直接往屏幕 DC 上画,每执行一个 GDI 函数屏幕就刷新一次;再加上窗口默认会先擦背景,EraseBkgnd 把整块刷白再重画,两件事叠一起就是闪屏。

解决:走 2.3 节的双缓冲,所有绘制进内存 DC,最后一次 BitBlt 上屏;同时所有 InvalidateRect 调用统一传 FALSE 禁止擦背景。只要这两点做到位,闪屏基本绝迹。这是我改这个项目时第一个动刀的地方,效果立竿见影。

5.2 点击偏位、选中落点错格

现象:点棋子右下角能选中,点中心反而选不中;落子位置总偏移半格,像棋盘和鼠标不在同一个坐标系。

原因:像素坐标转逻辑坐标时直接整除,没有四舍五入;或者根本没减 MARGIN 就直接除。格子是 60 像素宽,鼠标点在第 29 像素时整除结果是 0,点在第 31 像素时结果是 1,同一格的点击被分到了两个逻辑坐标。

解决:统一用(px - MARGIN_X + CELL_SIZE / 2) / CELL_SIZE,先加半格再整除;换算后先做边界检查,file/rank 越界直接 return。从那以后我写任何带点击命中的 Win32 界面,都强制先写坐标换算函数再加边界判断,两条缺一不可。

5.3 GDI 对象数只涨不跌

现象:程序运行十来分钟,打开任务管理器看进程的 GDI 对象数从几十涨到几百,棋盘绘制开始变得异常,极端情况下整块消失。

原因:CreatePen、CreateSolidBrush、CreateFont 创建的 GDI 对象没有 DeleteObject。GDI 对象是进程级资源,不像 DC 那样自动恢复,每次绘制泄漏几个,几百步棋下来就把进程的 10000 个 GDI 句柄额度吃光。

解决:所有 Create 开头的函数,使用后必须配对 DeleteObject。我的习惯是创建后马上在心里过一遍「SelectObject 旧对象 → DeleteObject 新对象」两条语句,写代码时这两行跟着创建函数一起落下,不留给后面补。你也可以写个简单的清理宏,但更重要的是形成肌肉记忆。

5.4 深搜索把窗口拖死

现象:点完轮到 AI 走,窗口立即无响应,标题栏出现「未响应」,过好几秒才恢复,期间鼠标拖动、按钮点击全部失效。

原因:AIPlay 在主线程里跑 Alpha-Beta 搜索,递归搜索把消息循环堵死了。WM_PAINT、WM_LBUTTONDOWN 全部在队列里排队,Windows 判定窗口无响应。

解决:简单方案是 WM_TIMER 驱动 AI,把搜索拆到定时器回调里,每步搜索控制在 100ms 内;正规方案是开工作线程搜索,结束用 PostMessage(hwnd, WM_AI_READY, 0, 0) 通知主窗口更新界面。等窗口卡死再去改架构就没有后悔药了,最好是 AI 模块从第一天就设计成「可放到线程里跑」的纯函数,不碰任何窗口句柄。

5.5 高 DPI 下棋盘与鼠标错位

现象:在 150% 缩放的显示器上,棋盘线条发虚,棋子中心点和鼠标点击位置对不上,点棋子左半边选中的却是右边那一格。

原因:进程默认不感知 DPI,系统把窗口内容按位图拉伸,但鼠标坐标是物理像素,GDI 绘制用的却是逻辑像素,两套坐标一拉伸就对不齐。

解决:在 WinMain 入口第一行调用 SetProcessDPIAware(),让进程自己处理缩放;然后把棋盘所有像素常量按当前 DPI 缩放:int scale = GetDpiForWindow(hwnd) / 96.0f,MARGIN 和 CELL_SIZE 全部乘这个系数。血泪经验:不要在旧代码上叠加一套换算,统一把像素常量改成 DPI 感知才是正路。

6. 进阶玩法:悔棋栈、FEN 打谱与规则回归验证

下棋功能完整之后,再往前走一步就是做游戏体验。悔棋和打谱是棋类程序两个最实用的增强功能,而且实现代价极低。悔棋只需要一张走法栈,把每一步的起点、终点、被吃掉的子记录下来:

typedef struct { int fx, fy, tx, ty, captured; } Step; Step undoStack[256]; int undoTop = -1; void ApplyMove(int fx, int fy, int tx, int ty) { undoTop++; undoStack[undoTop] = (Step){ fx, fy, tx, ty, grid[ty][tx] }; grid[ty][tx] = grid[fy][fx]; grid[fy][fx] = 0; } void UndoOne(void) { if (undoTop < 0) return; Step s = undoStack[undoTop--]; grid[s.fy][s.fx] = grid[s.ty][s.tx]; grid[s.ty][s.tx] = s.captured; }

人机对战时点悔棋要一次回退两步,因为你走一步、AI 回一步,只退一步局面就成「自己走完还没轮到红方」的非法状态。函数就一行:UndoOne(); UndoOne();,但注意第二次调用前要检查 undoTop 不为负。

比悔棋更进一步的是 FEN 打谱。FEN 用一串文本描述整个棋局,可以存档、复盘、灌残局。以初始局面为例,标准象棋 FEN 长这样:

rnbakabnr/9/1c5c1/p1p1p1p1p/9/9/P1P1P1P1P/1C5C1/9/RNBAKABNR w - - 0 1

解析逻辑很简单,按行切分,数字代表空位数,字母代表棋子,小写黑、大写红:

void LoadFEN(const char* fen) { int y = 0, x = 0; while (*fen && y < 10) { char ch = *fen++; if (ch == '/') { y++; x = 0; } else if (ch >= '1' && ch <= '9') x += ch - '0'; else grid[y][x++] = MapCharToPiece(ch); } }

有了 FEN 加载,我养成了一个习惯:每次改完走棋规则或 AI 评估函数,就把《适情雅趣》里几个经典杀法残局按 FEN 灌进去,跑一遍固定深度搜索,对比着法是否和棋谱一致。这个回归验证替代了肉眼打谱,能精准发现「规则改坏但看起来正常」的问题。从那以后我每次改完行棋逻辑,都强制走一遍 FEN 回归流程,确认杀法正解没被改歪才提交。一套 FEN 解析加三步残局验证,成本不到半小时,换来的是后面改代码再也不用担心把规则悄悄改坏。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询