简介:这份资源面向希望用 Visual Studio 上手桌面游戏开发的初学者与进阶学习者,围绕五子棋项目完整呈现人人对战与人机对战两种模式的实现思路,涵盖棋盘状态管理、合法落子判断、胜负检测以及基于搜索的 AI 落子策略等核心知识点,适合作为 C++ 图形编程与事件驱动开发的练手案例。压缩包共 49 个文件,约 107MB,以 cpp 源码与头文件为主体,辅以 jpg 棋盘棋子素材、wav 音效、png 界面图片,以及 sln、vcxproj 等工程配置文件和 pdb、log 等调试记录,工程结构完整,导入后即可编译运行。目前已有 2659 人学习下载,具备一定的参考基础。读者可从中获得可直接运行的完整工程、分模块组织的游戏逻辑代码、AI 对战实现参考,以及界面素材与音效资源,便于对照理解图形绘制、事件响应与算法设计的配合方式,并在此基础上扩展悔棋、存档等功能。
1. 从一份能直接跑起来的五子棋工程说起
很多人学完 C++ 语法后卡在同一个地方:知道类怎么写、知道数组怎么用,但真给一个「做个五子棋」的需求,脑子里还是空的。这份基于 Visual Studio 的五子棋工程就是冲着这个空档来的——它把棋盘绘制、落子判定、胜负检测、人机对战这几件事拆成能单独看懂的小块,用最朴素的 Win32 窗口和 GDI 绘图实现,不依赖任何第三方库,双击 sln 就能编译运行。适合两类人:一类是想找一个完整小项目练手、把语法串成工程的学生;另一类是熟手,想看看一个不带框架的桌面小游戏,人机对战的评估函数和搜索逻辑到底怎么落地。下面按「能干什么、怎么跑、核心怎么实现、怎么改」的顺序拆开讲。
2. 工程结构与技术选型:为什么是 Win32 + GDI 而不是 Qt
2.1 目录与文件职责划分
这个工程的结构非常扁平,典型的教学级 C++ 桌面项目布局,没有引入任何包管理器。常见做法是下面这样组织:
Gomoku/ ├── Gomoku.sln // VS 解决方案文件 ├── Gomoku/ │ ├── Gomoku.vcxproj // 项目文件,记录编译配置 │ ├── main.cpp // WinMain 入口,窗口创建与消息循环 │ ├── Game.h / Game.cpp // 游戏状态:棋盘、当前玩家、胜负标记 │ ├── Board.h / Board.cpp // 棋盘数据与落子、判胜的纯逻辑 │ ├── AI.h / AI.cpp // 人机对战:评估函数与搜索 │ ├── Render.h / Render.cpp // GDI 绘制:网格、棋子、提示 │ └── resource.h // 资源 ID 定义把Board和Game拆开是关键一步:Board只管二维数组和「这个点能不能下、下了之后谁赢」,不碰任何窗口句柄;Game管回合切换和状态机;Render只负责把数据画出来。这样后面想换 UI(比如换成控制台或 Qt)时,逻辑层可以原样搬走。新手最容易犯的错是把绘制和判定全塞进WndProc,几百行下去就再也改不动了。
2.2 技术选型:Win32 API + GDI 的取舍
选 Win32 而不是 Qt/MFC,理由很实际:零依赖、编译快、代码量可控。整个窗口就是一个CreateWindowEx加一个消息循环,绘图全部走WM_PAINT里的 GDI 调用。代价是要自己处理坐标换算、双缓冲防闪烁这些细节。对于五子棋这种 15×15 网格、每帧重绘成本极低的场景,GDI 完全够用,没必要上图形库。
棋盘用一维或二维int数组表示,常见约定是0空、1黑、2白。坐标换算的核心是一个常量:格子边长CELL_SIZE(比如 40 像素)和棋盘左上角偏移MARGIN。鼠标点击的屏幕坐标要先减去MARGIN,再除以CELL_SIZE并四舍五入,才能落到最近的交叉点上——五子棋是下在交叉点而不是格子中心,这一步换算错了,棋子就会整体偏半格,是新手第一个翻车点。
2.3 编译环境与运行前置条件
工程面向 Visual Studio 2019/2022,使用「Windows 桌面应用程序」模板,字符集建议设为 Unicode。打开.sln后直接Ctrl+Shift+B生成,F5运行。如果遇到LNK2019找不到WinMain,多半是项目类型选成了控制台;如果窗口一闪而过,检查消息循环里GetMessage的返回值判断是否写反。这些都属于环境层面的小坑,后面避坑章节会集中说。
3. 核心模块拆解:从一次鼠标点击到胜负判定
3.1 消息循环与鼠标落子入口
整个程序的骨架是标准 Win32 消息循环。真正和游戏相关的是WM_LBUTTONDOWN的处理:把屏幕坐标转成棋盘下标,校验合法性,然后交给Game层。
// main.cpp 片段:窗口过程里的鼠标处理 LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_LBUTTONDOWN: { int x = LOWORD(lParam); // 鼠标 x 像素坐标 int y = HIWORD(lParam); // 鼠标 y 像素坐标 int col = (x - MARGIN + CELL_SIZE / 2) / CELL_SIZE; // 四舍五入到最近交叉点 int row = (y - MARGIN + CELL_SIZE / 2) / CELL_SIZE; if (row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE) { g_game.HandleClick(row, col); // 交给游戏层:判空、落子、判胜、切回合 InvalidateRect(hWnd, nullptr, FALSE); // 请求重绘 } return 0; } case WM_PAINT: g_render.Paint(hWnd); // 所有绘制集中在 Render 里 return 0; case WM_DESTROY: PostQuitMessage(0); return 0; } return DefWindowProc(hWnd, msg, wParam, lParam); }这里MARGIN是棋盘左上角到窗口边缘的留白,CELL_SIZE是相邻交叉点间距。加CELL_SIZE / 2再整除,是把「点击落在交叉点附近」吸附到最近的合法点,避免玩家必须点得像素级精准。HandleClick内部先查board[row][col] != 0直接返回,再落子、调判胜、没结束就切玩家。把这段逻辑从WndProc里抽出去,是为了让 AI 也能复用同一套落子规则——AI 走子时不该走窗口消息。
3.2 胜负判定:以落子点为中心的四方向扫描
判胜不需要扫全盘,只需要从刚下的那颗子出发,沿横、竖、两条对角线各数一遍连续同色棋子。这是性能上最划算的写法,也是面试里常被问的点。
// Board.cpp:判断 (row,col) 落子后是否五连 bool Board::CheckWin(int row, int col, int player) { // 四个方向:横、竖、主对角、副对角 const int dirs[4][2] = {{0,1}, {1,0}, {1,1}, {1,-1}}; for (auto& d : dirs) { int count = 1; // 当前这颗子 // 正方向延伸 for (int i = 1; i < 5; ++i) { int r = row + d[0] * i, c = col + d[1] * i; if (r < 0 || r >= BOARD_SIZE || c < 0 || c >= BOARD_SIZE) break; if (data[r][c] != player) break; ++count; } // 反方向延伸 for (int i = 1; i < 5; ++i) { int r = row - d[0] * i, c = col - d[1] * i; if (r < 0 || r >= BOARD_SIZE || c < 0 || c >= BOARD_SIZE) break; if (data[r][c] != player) break; ++count; } if (count >= 5) return true; // 五连即胜 } return false; }dirs里四个方向覆盖了所有可能的连线;每个方向从落子点向两侧各延伸最多 4 格,因为超过 5 连也一定包含 5 连,没必要数满。边界判断放在取数组之前,避免越界读。player参数传当前落子方,这样黑棋白棋共用一份逻辑。注意这里只判「是否五连」,不处理禁手规则——标准五子棋的黑棋三三、四四禁手属于进阶需求,教学工程一般不做,二次开发时可以在这里加分支。
3.3 人机对战:评估函数 + 极小化极大搜索
AI 部分是这个工程最值得看的地方。常见实现是「局面评估 + 带 Alpha-Beta 剪枝的极小化极大搜索」。评估函数给每个空点打分,搜索则模拟双方轮流落子若干层,选对自己最有利的一步。
// AI.cpp:单点评估的简化版,统计该点四个方向的连子形态 int AI::EvaluatePoint(int row, int col, int player) { int score = 0; const int dirs[4][2] = {{0,1}, {1,0}, {1,1}, {1,-1}}; for (auto& d : dirs) { int myCount = 1, block = 0; // 己方连子数、被封堵端数 for (int sign : {1, -1}) { for (int i = 1; i < 5; ++i) { int r = row + d[0] * i * sign, c = col + d[1] * i * sign; if (r < 0 || r >= BOARD_SIZE || c < 0 || c >= BOARD_SIZE) { ++block; break; } if (data[r][c] == player) ++myCount; else if (data[r][c] == 0) break; // 空位,延伸到此为止 else { ++block; break; } // 对方子,封堵 } } // 连子越多分越高,两端被封则大幅降权 if (myCount >= 5) score += 100000; else if (myCount == 4 && block == 0) score += 10000; else if (myCount == 4 && block == 1) score += 1000; else if (myCount == 3 && block == 0) score += 1000; else if (myCount == 3 && block == 1) score += 100; else if (myCount == 2 && block == 0) score += 100; else score += myCount * 10; } return score; }这段是「启发式打分」的核心:活四(两端都空)几乎必胜,给最高分;冲四(一端被封)次之;活三次之。block记录两端被封的次数,myCount是含当前点在内的连续己方子数。搜索层再对每个候选点调用EvaluatePoint,用「己方得分减对方得分」作为局面值,配合 Alpha-Beta 剪枝把搜索深度控制在 3~4 层,保证在普通机器上落子响应在一秒内。候选点不要全盘遍历,只取已有棋子周围两格内的空点,能把分支数从 225 降到几十,这是让 AI 不卡顿的关键优化。
3.4 GDI 绘制与双缓冲防闪烁
绘制集中在WM_PAINT。直接往窗口 DC 上画,每次重绘会看到明显闪烁,标准解法是内存 DC 双缓冲:先画到兼容位图,再一次性BitBlt到窗口。
// Render.cpp:双缓冲绘制棋盘与棋子 void Render::Paint(HWND hWnd) { PAINTSTRUCT ps; HDC hdc = BeginPaint(hWnd, &ps); HDC memDC = CreateCompatibleDC(hdc); HBITMAP bmp = CreateCompatibleBitmap(hdc, WIDTH, HEIGHT); HBITMAP old = (HBITMAP)SelectObject(memDC, bmp); // 背景 FillRect(memDC, &ps.rcPaint, (HBRUSH)GetStockObject(WHITE_BRUSH)); // 画 15x15 网格线 for (int i = 0; i < BOARD_SIZE; ++i) { int p = MARGIN + i * CELL_SIZE; MoveToEx(memDC, MARGIN, p, nullptr); LineTo(memDC, MARGIN + (BOARD_SIZE-1)*CELL_SIZE, p); MoveToEx(memDC, p, MARGIN, nullptr); LineTo(memDC, p, MARGIN + (BOARD_SIZE-1)*CELL_SIZE); } // 画棋子:黑用实心圆,白用空心圆 for (int r = 0; r < BOARD_SIZE; ++r) for (int c = 0; c < BOARD_SIZE; ++c) if (board.At(r, c) != 0) DrawStone(memDC, r, c, board.At(r, c)); BitBlt(hdc, 0, 0, WIDTH, HEIGHT, memDC, 0, 0, SRCCOPY); // 一次性贴回 SelectObject(memDC, old); DeleteObject(bmp); DeleteDC(memDC); EndPaint(hWnd, &ps); }CreateCompatibleDC建内存画布,CreateCompatibleBitmap建同尺寸位图,所有绘制在memDC上完成,最后BitBlt一次贴到屏幕。SelectObject保存旧对象、结束时恢复并DeleteObject,否则会泄漏 GDI 对象——这是 Win32 里最常见的资源泄漏来源,长时间运行后句柄耗尽,窗口直接画不出来。
4. 避坑与常见问题:编译、坐标、AI 卡顿的排查清单
4.1 编译链接报错:入口点与字符集
现象:生成时报LNK2019: 无法解析的外部符号 WinMain。原因通常是项目被建成了「控制台应用程序」,链接器找的是main而不是WinMain。解决:项目属性 → 链接器 → 系统 → 子系统改为「Windows (/SUBSYSTEM:WINDOWS)」,入口点留空即可。另一个高频问题是const char*传给需要LPCWSTR的 API 报类型不匹配,那是字符集设成了 Unicode,要么统一用TEXT()宏包字符串,要么把字符集改成多字节——推荐前者,别为了省事改字符集。
4.2 棋子位置整体偏移半格
现象:点交叉点,棋子却画在格子中心或偏一格。原因:坐标换算时忘了五子棋下在交叉点,或者MARGIN与绘制时的起点不一致。解决:绘制网格和换算点击必须共用同一组MARGIN、CELL_SIZE常量,最好定义在头文件里只写一次。换算公式统一用(pixel - MARGIN + CELL_SIZE/2) / CELL_SIZE,别一处加偏移一处不加。
4.3 AI 落子后界面不刷新
现象:AI 算完了,棋盘上却没出现它的子,要再点一下才显示。原因:AI 走子后没有触发InvalidateRect,或者 AI 计算放在 UI 线程里阻塞了消息循环。解决:AI 落子后同样调InvalidateRect(hWnd, nullptr, FALSE);如果搜索深度大导致卡顿,把 AI 计算放到工作线程,算完PostMessage通知主线程重绘,别在WndProc里同步跑深搜。
4.4 长时间运行后窗口变白、画不出东西
现象:玩几十局后重绘失效。原因:GDI 对象泄漏,CreateCompatibleDC/CreateCompatibleBitmap创建后没释放。解决:严格配对Create/Delete,SelectObject换出的旧对象要恢复。可以在任务管理器里看进程的 GDI 对象数,持续上涨就是泄漏。
4.5 判胜漏判斜向五连
现象:横向竖向能判赢,斜着五连却不结束。原因:方向数组只写了{0,1}和{1,0},漏了对角线;或者副对角线方向写成{1,1}两次。解决:四个方向固定为{0,1}、{1,0}、{1,1}、{1,-1},写个单元测试在空棋盘上手动摆五连逐个方向验证,比肉眼盯代码靠谱。
5. 二次开发改造:把教学工程变成能拿得出手的作品
5.1 三个低成本高回报的改造方向
这份工程的骨架够干净,改造空间很大。我一般会从这三个方向下手,按投入产出排序:
| 改造项 | 改动位置 | 难度 | 效果 |
|---|---|---|---|
| 悔棋 / 重开 | Game 层加落子历史栈 | 低 | 体验立刻完整 |
| 难度分级 | AI 搜索深度参数化 | 低 | 简单/普通/困难三档 |
| 禁手规则 | Board 判胜前加黑棋检查 | 中 | 接近正式规则 |
| 换皮与动画 | Render 层 | 中 | 落子缩放、最后一手高亮 |
悔棋的实现要点是维护一个std::vector<std::pair<int,int>>落子栈,人机对战要一次弹两步(玩家一步、AI 一步),否则会出现「悔棋后轮到 AI 又立刻下回来」的诡异循环。难度分级就是把搜索深度从 1/2/4 做成参数,深度 1 的 AI 基本是「只看一步」,适合新手。
5.2 验证改造是否正确的习惯
改完判胜或 AI 逻辑,别靠手动下棋碰运气。我的做法是写一个极简的测试入口:构造一个Board,手动填几组固定局面(活四、冲四、双三、边界五连),断言CheckWin和EvaluatePoint的返回值符合预期。这套测试不依赖窗口,纯逻辑层跑,几秒钟出结果。血泪经验是:AI 的评估分数一旦调错,表现不是报错而是「下得莫名其妙」,没有测试根本定位不到是哪一档分值写反了。
5.3 一个具体技巧:候选点剪枝让 AI 从卡顿到秒回
如果只让我改一处,我会改 AI 的候选点生成。原始写法遍历全盘 225 个点,深度 4 的分支数爆炸。改成只取「已有棋子周围两格内的空点」,再按EvaluatePoint预排序、优先搜高分点,配合 Alpha-Beta,实测响应能从好几秒降到一秒内。代码上就是在生成候选列表时加一个邻域检查:
// 只保留已有棋子周围 2 格内的空点,并按启发分降序 std::vector<Point> AI::GenCandidates(int player) { std::vector<Point> cand; for (int r = 0; r < BOARD_SIZE; ++r) for (int c = 0; c < BOARD_SIZE; ++c) { if (data[r][c] != 0 || !HasNeighbor(r, c, 2)) continue; cand.push_back({r, c, EvaluatePoint(r, c, player)}); } std::sort(cand.begin(), cand.end(), [](const Point& a, const Point& b){ return a.score > b.score; }); return cand; }HasNeighbor(r, c, 2)检查该点两格内是否有任何棋子,空棋盘中心附近才不会被误剪。预排序让剪枝更早生效,是 Alpha-Beta 的标准加速手段。改完记得用固定局面回归一遍,确认 AI 不会因为剪枝漏掉必胜点——我吃过一次亏,邻域半径设成 1,结果隔一格的活三没被识别,AI 眼睁睁看着对面连五。从那以后我每次动搜索参数,都强制把几组必胜/必防局面跑一遍再提交。希望这些拆解能帮到你,把这份工程跑起来、改出自己的版本。
本文还有配套的精品资源,点击获取