简介:面向 Windows 平台图形界面编程初学者与棋类游戏开发爱好者的实战源码包,围绕窗口、消息、绘图接口与基础搜索,演示如何从零搭建一款可运行的中国象棋程序,适合课程设计、入门练习与小型项目参考。压缩包共38个文件、约24KB,以18个图标、6个头文件、4个源代码文件为主,辅以光标、资源脚本、工程配置及说明文档,涵盖界面绘制、棋局表示、思考模块与工程组织等环节,体积轻量便于携带和二次修改。已有126人学习,读者可获取窗口创建与鼠标拖放处理、棋盘二维数组与棋子类设计、走法合法性判断与将军检测、极小极大搜索配合剪枝的智能对手思路,以及资源打包、调试排错和模块拆读复用方式,为课程设计和小型棋类项目提供可参考的实现骨架。
1. 一套纯 Win32 的象棋源码:不装引擎也能把一盘棋走完
前阵子帮人看一份课程设计,解压出来十几个文件,没有 Qt,没有 MFC,没有任何第三方库,双击 exe 就是一张木纹棋盘。标题写的是 windows sdk 象棋,第一反应是「这年头谁还手写 Win32」,翻完代码反而觉得选型挺对:象棋是回合制,界面固定,交互只有鼠标点击,窗口不需要复杂布局,GDI 加消息循环足够,连 UI 框架都省了。这套源码干的事很明确——9×10 交叉点棋盘、WM_LBUTTONDOWN接点击、一维数组存 90 个格点、把马腿象眼炮架写成判断函数、再挂一个 Alpha-Beta 搜索当对手。想跑通一个完整 Win32 图形程序又不想啃 MFC 的新手,或者手里有同类课程设计、想把棋规和悔棋补扎实的开发者,都能从里面拿到东西。
2. 工程骨架拆解:WinMain、WndProc 与资源文件各管一摊
一套 Win32 程序最容易写乱的地方不是棋规,而是把窗口、绘制、规则、AI 全塞进WndProc里,三百行之后谁都改不动。这份源码的分层不算精致,但边界是清楚的:窗口层只负责接消息,棋规层完全不认识windows.h,AI 层只吃一个Position结构。把这条边界守住,后面加功能才不会互相踩。
2.1 目录与文件职责
文件规模不大,按职责拆成下面这些,基本就是一份能直接交给别人维护的骨架:
| 文件 | 职责 | 容易出问题的地方 |
|---|---|---|
main.cpp | wWinMain、注册窗口类、消息循环 | 字符集要和.rc保持一致 |
wndproc.cpp | WndProc、鼠标/定时器/自定义消息分发 | 不要在这里写走法判断 |
board.h/.cpp | 棋盘数组、落子、悔棋、胜负判定 | 只依赖标准库,方便单测 |
movegen.cpp | 各兵种走法生成、合法性过滤 | 马腿、象眼、炮架最容易漏 |
search.cpp | 评估函数、Alpha-Beta、根节点选步 | 和 UI 解耦,才能开线程跑 |
render.cpp | 双缓冲、贴图、选中高亮 | 只碰 GDI,不碰棋规 |
resource.h/chess.rc | 位图、图标、菜单 ID | ID 手写容易撞号 |
res/*.bmp | 棋盘底图、棋子图 | 建议 32 位带 Alpha,缩放不糊 |
一个值得照抄的习惯:board.h里只出现int8_t、std::vector这类标准类型,不#include <windows.h>。这样棋规部分可以直接编成一个控制台测试程序,跑 perft 或者残局用例,不用每次开窗口点鼠标。
2.2 入口与消息循环
wWinMain干的事很固定:注册窗口类、创建窗口、进消息循环。真正有讲究的是背景刷和窗口样式这两处。
// main.cpp #include <windows.h> #include "resource.h" LRESULT CALLBACK WndProc(HWND, UINT, WPARAM, LPARAM); int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int nCmdShow) { WNDCLASSEXW wc = { sizeof(wc) }; wc.lpfnWndProc = WndProc; wc.hInstance = hInst; wc.hCursor = LoadCursor(nullptr, IDC_ARROW); wc.hbrBackground = nullptr; // 背景由 WM_PAINT 自绘,别让系统擦 wc.lpszClassName = L"ChessWnd"; wc.hIcon = LoadIconW(hInst, MAKEINTRESOURCEW(IDI_APP)); RegisterClassExW(&wc); // 客户区按 9x10 格点算,窗口尺寸留出标题栏和边框 HWND hwnd = CreateWindowExW(0, L"ChessWnd", L"象棋", WS_OVERLAPPEDWINDOW & ~WS_MAXIMIZEBOX, CW_USEDEFAULT, CW_USEDEFAULT, 760, 860, nullptr, nullptr, hInst, nullptr); if (!hwnd) return 0; ShowWindow(hwnd, nCmdShow); UpdateWindow(hwnd); MSG msg; while (GetMessageW(&msg, nullptr, 0, 0) > 0) { // 注意是 > 0,不是 != 0 TranslateMessage(&msg); DispatchMessageW(&msg); } return (int)msg.wParam; }GetMessageW的返回值有三种:大于 0 是正常消息,等于 0 是WM_QUIT,等于 -1 是出错。写成while (GetMessage(...))会把 -1 也当成真值继续转,虽然实际很少碰到,但排查死循环时这行会被反复怀疑。TranslateMessage负责把WM_KEYDOWN翻译成WM_CHAR,这个项目如果没做键盘操作,去掉也不影响,但留着不亏。窗口样式去掉WS_MAXIMIZEBOX,是因为棋盘格距是按客户区尺寸算出来的,最大化之后要么留白难看,要么得重算坐标,干脆不给这个入口。
2.3 窗口过程与状态机
WndProc只做分发,具体逻辑全部转给Game_*系列函数。这样后面加「悔棋」「认输」「换边」这些菜单项时,只加 case 分支,不动棋规。
// wndproc.cpp LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { switch (msg) { case WM_CREATE: Game_Init(hwnd); // 建后备缓冲、加载位图、重置棋局 SetTimer(hwnd, TIMER_ANIM, 16, nullptr); // 约 60fps 的动画节拍 return 0; case WM_ERASEBKGND: return 1; // 告诉系统:背景我自己画,别擦 case WM_PAINT: Game_Paint(hwnd); // 内部走双缓冲 return 0; case WM_LBUTTONDOWN: { int px = GET_X_LPARAM(lp); int py = GET_Y_LPARAM(lp); Game_OnClick(hwnd, px, py); // 选中/落子/弹提示都在这 return 0; } case WM_TIMER: if (wp == TIMER_ANIM) Game_OnTick(hwnd); return 0; case WM_COMMAND: switch (LOWORD(wp)) { case IDM_NEW: Game_New(hwnd); break; case IDM_UNDO: Game_Undo(hwnd); break; case IDM_FLIP: Game_FlipSide(hwnd); break; } return 0; case WM_DESTROY: KillTimer(hwnd, TIMER_ANIM); Game_Cleanup(hwnd); // 释放 DC、位图,别等进程退出 PostQuitMessage(0); return 0; } return DefWindowProcW(hwnd, msg, wp, lp); }GET_X_LPARAM/GET_Y_LPARAM在windowsx.h里,作用是把LPARAM的低 16 位和高 16 位拆出来,并且带符号扩展。直接写LOWORD(lp)在负坐标(比如鼠标拖到客户区左侧外)时结果会错,虽然象棋棋盘一般不会点到负坐标,但养成用宏的习惯没坏处。WM_ERASEBKGND返回 1 是双缓冲的必要前提,不返回的话系统会在WM_PAINT之前先用背景刷涂一遍,画面就会闪。
2.4 资源和编译方式
棋子和棋盘走的是位图资源,.rc文件里一行一个 ID,加载时用LoadImageW而不是LoadBitmapW。前者能指定宽高和LR_CREATEDIBSECTION,缩放时质量更好。
// chess.rc 片段 IDI_APP ICON "res/app.ico" IDB_BOARD BITMAP "res/board.bmp" IDB_PIECE_RED BITMAP "res/pieces_red.bmp" IDB_PIECE_BLK BITMAP "res/pieces_black.bmp"// render.cpp:一次性把所有位图转成内存位图,避免每帧 LoadBitmap void LoadImages(HINSTANCE hInst) { g_img.board = (HBITMAP)LoadImageW(hInst, MAKEINTRESOURCEW(IDB_BOARD), IMAGE_BITMAP, 0, 0, LR_CREATEDIBSECTION | LR_DEFAULTCOLOR); g_img.red = (HBITMAP)LoadImageW(hInst, MAKEINTRESOURCEW(IDB_PIECE_RED), IMAGE_BITMAP, 0, 0, LR_CREATEDIBSECTION | LR_DEFAULTCOLOR); // 两个位图都要判空,资源 ID 写错时 LoadImage 返回 nullptr,后面贴图会静默变黑 }编译上有两个坑要提前说清楚。一是项目属性里的「字符集」必须和代码里的wWinMain/L"..."对得上,选「使用多字节字符集」而代码写宽字符,链接阶段就会报找不到WinMain。二是资源文件里的路径是相对.rc所在目录的,用 Visual Studio 打开没问题,换成cmake生成的项目,res目录要确保被拷到输出目录,否则运行起来棋盘是一片空白。
3. 棋盘与走法生成:90 个格点里的马腿、象眼和炮架
棋规是这类项目里最值得花时间的地方。界面丑一点没人计较,走法错一步,整盘棋的可信度就没了。这部分代码建议单独编译、单独测,不要跟窗口混在一起调。
3.1 棋盘表示与棋子编码
棋盘用一维int8_t board[90],索引y * 9 + x,x从 0 到 8,y从 0 到 9。y = 0是黑方底线,y = 9是红方底线。一维数组的好处是拷贝便宜,做搜索时每个节点复制一份 90 字节,比二维数组加 vector 快得多。
// board.h enum Side { RED = 0, BLACK = 1 }; enum PieceType { EMPTY = 0, KING = 1, ADVISOR = 2, ELEPHANT = 3, HORSE = 4, ROOK = 5, CANNON = 6, PAWN = 7 }; // 低 4 位存类型,bit4 存颜色,0 表示空格 inline int MakePiece(Side s, PieceType t) { return (s << 4) | t; } inline int PieceSide(int p) { return (p >> 4) & 1; } inline int PieceTypeOf(int p) { return p & 0x0F; } inline bool IsRed(int p) { return PieceSide(p) == RED; } struct Position { int8_t board[90]; int side; // 轮到谁走 int halfMove; // 距上次吃子的半回合数,用于自然限着 };用一个字节同时表达颜色和类型,好处是判断「这个格子是不是敌方棋子」只要比较 bit4,不用查表。代价是可读性差一点,所以PieceTypeOf这类内联函数要写全,别在业务代码里直接p & 0x0F。halfMove是为六十回合自然限着准备的,很多课程设计版会漏掉这个字段,结果残局里双方一直绕圈,程序永远判不出和棋。
3.2 走法生成:滑子与跳子的边界
车和炮是滑子,沿四个方向一直走;马、象、士、将是跳子,走固定偏移。两类分开写,比塞进一个大switch里清楚。
// movegen.cpp:车,四个方向滑到第一个子为止 static const int DX[4] = {0, 0, 1, -1}; static const int DY[4] = {1, -1, 0, 0}; void GenRook(const Position& pos, int from, std::vector<Move>& out) { int x = from % 9, y = from / 9; int me = PieceSide(pos.board[from]); for (int d = 0; d < 4; ++d) { int nx = x + DX[d], ny = y + DY[d]; while (nx >= 0 && nx < 9 && ny >= 0 && ny < 10) { int to = ny * 9 + nx; if (pos.board[to] == 0) { out.push_back({ from, to }); // 空格,继续滑 } else { if (PieceSide(pos.board[to]) != me) out.push_back({ from, to }); // 敌子,吃,然后停 break; // 己子或敌子都到此为止 } nx += DX[d]; ny += DY[d]; } } }炮和车的区别只在「遇到第一个子之后」。车碰到子就停,炮要跳过第一个子(炮架),再看第二个子是不是敌方:
// movegen.cpp:炮,先走空格,翻过一个炮架后再判吃 void GenCannon(const Position& pos, int from, std::vector<Move>& out) { int x = from % 9, y = from / 9; int me = PieceSide(pos.board[from]); for (int d = 0; d < 4; ++d) { int nx = x + DX[d], ny = y + DY[d]; bool jumped = false; // 是否已经翻过炮架 while (nx >= 0 && nx < 9 && ny >= 0 && ny < 10) { int to = ny * 9 + nx; if (!jumped) { if (pos.board[to] == 0) out.push_back({ from, to }); else jumped = true; // 第一个子当炮架,不吃 } else { if (pos.board[to] != 0) { // 炮架后第一个子 if (PieceSide(pos.board[to]) != me) out.push_back({ from, to }); break; // 无论敌友都停 } } nx += DX[d]; ny += DY[d]; } } }马腿是新手最容易写错的地方:马走「日」,腿在长边那一格。判断方式是看偏移量绝对值等于 2 的那个方向,取一半作为腿的坐标:
static const int HX[8] = { 1, 2, 2, 1, -1, -2, -2, -1}; static const int HY[8] = { 2, 1, -1, -2, -2, -1, 1, 2}; void GenHorse(const Position& pos, int from, std::vector<Move>& out) { int x = from % 9, y = from / 9; int me = PieceSide(pos.board[from]); for (int i = 0; i < 8; ++i) { int nx = x + HX[i], ny = y + HY[i]; if (nx < 0 || nx > 8 || ny < 0 || ny > 9) continue; int legX = x + (HX[i] == 2 ? 1 : HX[i] == -2 ? -1 : 0); int legY = y + (HY[i] == 2 ? 1 : HY[i] == -2 ? -1 : 0); if (pos.board[legY * 9 + legX] != 0) continue; // 蹩马腿 if (pos.board[ny * 9 + nx] == 0 || PieceSide(pos.board[ny * 9 + nx]) != me) out.push_back({ from, ny * 9 + nx }); } }象走田,塞象眼在中点;士和将限制在九宫;兵过河前只能向前,过河后可以横走。这四类逻辑简单,但边界条件要一条条对:象不能过河(红象y >= 5,黑象y <= 4),士的九宫是x ∈ [3,5],红方y ∈ [7,9]、黑方y ∈ [0,2],将同理。把这些范围写死成常量,别在函数里散落魔法数字。
3.3 合法性过滤:走后不能自将、将帅不能照面
生成出来的是伪合法走法,还得过两道筛子:走完这步,自己的将不能被将军;双方将不能在同一列且中间无子。后者是中国象棋特有的规则,漏掉的话会出现「两将对脸」的诡异局面。
// board.cpp:从将的位置反向查,比生成对方全部走法快 bool InCheck(const Position& pos, int side) { int kingSq = FindKing(pos, side); if (kingSq < 0) return true; // 将都被吃了,算被将死 int opp = 1 - side; int kx = kingSq % 9, ky = kingSq / 9; // 车/炮/将的直线方向:先找第一个子,再看第二个子是不是炮 for (int d = 0; d < 4; ++d) { int nx = kx + DX[d], ny = ky + DY[d], first = -1; while (nx >= 0 && nx < 9 && ny >= 0 && ny < 10) { int sq = ny * 9 + nx; if (pos.board[sq] != 0) { if (first < 0) { first = sq; int t = PieceTypeOf(pos.board[sq]); if (PieceSide(pos.board[sq]) == opp && (t == ROOK || t == KING)) return true; // 车或将贴脸 } else { if (PieceSide(pos.board[sq]) == opp && PieceTypeOf(pos.board[sq]) == CANNON) return true; // 炮隔子 break; } } nx += DX[d]; ny += DY[d]; } } // 马的位置同样反查,八个方向看有没有敌马,同时判腿 // 兵只需看身前和左右三个方向 return false; } bool IsLegal(const Position& pos, Move m) { Position next = pos; MakeMove(next, m); if (InCheck(next, pos.side)) return false; if (KingsFace(next)) return false; // 将帅照面 return true; }KingsFace的实现很直接:找双方将的坐标,如果x相同,沿这一列从中间扫一遍,没有子就返回true。这一步放在IsLegal里而不是MakeMove里,是因为搜索过程中需要快速落子,合法性过滤只在根节点和生成走法列表时做一次。
3.4 悔棋与局面快照
悔棋不要试图「反向走一步」,象棋没有严格可逆的逆操作(吃过子的位置、兵过河的状态都不好还原)。正确做法是每次落子前把关键信息
本文还有配套的精品资源,点击获取