资讯详情

纯Win32象棋源码:消息循环、GDI双缓冲与Alpha-Beta搜索

📅 2026/10/31 11:55:05 | 华诺云谱 👁 阅读
纯Win32象棋源码:消息循环、GDI双缓冲与Alpha-Beta搜索
简介面向 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.hAI 层只吃一个Position结构。把这条边界守住后面加功能才不会互相踩。2.1 目录与文件职责文件规模不大按职责拆成下面这些基本就是一份能直接交给别人维护的骨架文件职责容易出问题的地方main.cppwWinMain、注册窗口类、消息循环字符集要和.rc保持一致wndproc.cppWndProc、鼠标/定时器/自定义消息分发不要在这里写走法判断board.h/.cpp棋盘数组、落子、悔棋、胜负判定只依赖标准库方便单测movegen.cpp各兵种走法生成、合法性过滤马腿、象眼、炮架最容易漏search.cpp评估函数、Alpha-Beta、根节点选步和 UI 解耦才能开线程跑render.cpp双缓冲、贴图、选中高亮只碰 GDI不碰棋规resource.h/chess.rc位图、图标、菜单 IDID 手写容易撞号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 LChessWnd; wc.hIcon LoadIconW(hInst, MAKEINTRESOURCEW(IDI_APP)); RegisterClassExW(wc); // 客户区按 9x10 格点算窗口尺寸留出标题栏和边框 HWND hwnd CreateWindowExW(0, LChessWnd, 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 xx从 0 到 8y从 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::vectorMove 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::vectorMove 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::vectorMove 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 悔棋与局面快照悔棋不要试图「反向走一步」象棋没有严格可逆的逆操作吃过子的位置、兵过河的状态都不好还原。正确做法是每次落子前把关键信息本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑