简介:本资源是一份基于Visual Studio开发的五子棋桌面游戏完整工程,面向C++初学者与图形界面编程学习者,聚焦人机对战与人人对战双模式实现,覆盖GUI设计、事件响应、AI算法(含基础穷举与胜负判定逻辑)及音画资源集成等核心实践环节。压缩包共49个文件,约107MB,包含5个cpp源文件(如main.cpp、ai.cpp、game.cpp)、1个头文件(renju.h)、11幅界面与棋盘图片(jpg/png)、5段音效(wav)、1个Visual Studio解决方案(sln)及配套vcxproj工程配置,另有调试符号(pdb)、编译中间文件(tlog/obj)等完整构建产物,结构清晰,便于逐模块理解与二次开发。目前已有2659人学习下载,读者可直接运行调试、分析AI落子策略、复用棋盘渲染与胜负检测逻辑,或拓展悔棋、存档等功能,是掌握Windows桌面游戏开发流程的典型教学级项目。
1. 五子棋不是练手小项目,而是验证你是否真懂 Win32 消息循环的照妖镜
去年帮某高校实验室带毕设,三个学生交上来的人机五子棋,两个跑不起来,一个能下但鼠标点空格没反应——查了两小时才发现是WM_LBUTTONDOWN消息里忘了调用SetFocus(hwnd),导致后续键盘输入被系统吞掉。这项目表面看只是画个棋盘、判个胜负,实则把 Win32 编程最核心的三块硬骨头全串起来了:消息泵如何调度用户输入、GDI 绘图如何与窗口生命周期对齐、AI 决策如何在单线程 GUI 中不卡死界面。它不适合纯新手从零开干,但特别适合刚学完 C++ 基础、想亲手拆解一个「有真实交互、有逻辑分支、有性能边界」的桌面应用的开发者。VS 2019/2022 原生支持,无需额外 SDK,编译即跑;人机对战模块用极简 Minimax + 启发式剪枝,代码不到 200 行却足够应对中等水平玩家;所有绘图逻辑封装在DrawBoard()和DrawStone()里,改个颜色、换种棋子样式,5 分钟就能看到效果。如果你正卡在「学完语法却写不出可交互程序」的瓶颈期,这个项目就是那把该捅破的窗户纸。
2. 工程结构与核心模块拆解:从资源文件到主窗口过程的完整链路
2.1 项目目录与关键文件职责划分
整个 VS 工程采用经典 Win32 应用布局,无第三方依赖,全部源码控制在 4 个核心文件内:
Gomoku/ ├── Gomoku.cpp // WinMain 入口、窗口注册、消息循环主干 ├── GameLogic.h/.cpp // 棋盘状态管理、胜负判定、AI 决策核心 ├── BoardRenderer.h/.cpp// GDI 绘图封装:棋盘网格、棋子渲染、落子动画(可选) ├── Resource.h // 菜单 ID、字符串资源定义(如“重新开始”“悔棋”) └── Gomoku.rc // 图标、菜单、加速键资源(含“人机对战”“双人对战”切换项)提示:
GameLogic.h是唯一暴露给 UI 层的头文件,所有游戏规则(如IsWin()判定逻辑、GetBestMove()AI 接口)均通过该头文件声明,实现细节完全隐藏。这种设计让后期替换 AI 算法(比如换成 MCTS)时,只需重写GameLogic.cpp,UI 层一行代码不用动。
2.2 主窗口过程(WndProc)的关键消息处理逻辑
Gomoku.cpp中的WndProc是整个交互的中枢,必须精准响应三类消息:
- 窗口创建与重绘:
WM_CREATE初始化棋盘状态,WM_PAINT触发BoardRenderer::DrawBoard(); - 用户输入:
WM_LBUTTONDOWN捕获鼠标点击坐标并转换为棋盘格索引,WM_KEYDOWN处理空格悔棋、R 键重开; - 尺寸变更:
WM_SIZE重新计算棋盘缩放比例,确保不同分辨率下网格均匀。
核心代码段如下(Gomoku.cpp中WndProc函数内):
case WM_LBUTTONDOWN: { POINT pt = { GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam) }; int row, col; if (BoardToGrid(pt, &row, &col)) { // 将屏幕坐标转为 [0..14][0..14] 棋盘索引 if (gameState.IsValidMove(row, col)) { gameState.MakeMove(row, col, PLAYER_BLACK); InvalidateRect(hwnd, NULL, TRUE); // 标记全窗重绘 UpdateWindow(hwnd); // 强制立即刷新,避免点击延迟感 // 人机对战模式下,AI 落子需异步触发(见 2.3 节说明) if (gameMode == GAME_MODE_AI && !gameState.IsGameOver()) { PostMessage(hwnd, WM_COMMAND, ID_AI_MOVE, 0); // 发送自定义消息触发 AI } } } break; }参数说明:
GET_X_LPARAM/GET_Y_LPARAM是 Win32 宏,安全提取lParam中的坐标值;BoardToGrid()内部根据当前窗口客户区大小和预设边距,将像素坐标映射到 15×15 棋盘格,精度控制在 ±1 像素内;InvalidateRect(..., TRUE)的TRUE参数表示擦除背景,避免棋子重叠残留。
2.3 AI 决策模块的非阻塞实现:为什么不能直接在 WndProc 里调用 GetBestMove()
初学者最容易翻车的地方:把gameState.GetBestMove()直接写在WM_LBUTTONDOWN分支里。后果是——鼠标点击后界面假死 1~2 秒,用户以为程序崩溃。根本原因是 Minimax 搜索在主线程同步执行,阻塞了消息泵。
正确做法是使用PostMessage发送自定义消息WM_COMMAND(ID 为ID_AI_MOVE),并在WndProc的WM_COMMAND分支中处理:
case WM_COMMAND: switch (LOWORD(wParam)) { case ID_AI_MOVE: if (!gameState.IsGameOver()) { auto [aiRow, aiCol] = gameState.GetBestMove(); // 返回 pair<int,int> gameState.MakeMove(aiRow, aiCol, PLAYER_WHITE); InvalidateRect(hwnd, NULL, TRUE); UpdateWindow(hwnd); // 检查 AI 是否获胜 if (gameState.IsWin(aiRow, aiCol, PLAYER_WHITE)) { MessageBox(hwnd, "电脑获胜!", "游戏结束", MB_OK | MB_ICONINFORMATION); } } break; } break;关键逻辑:
PostMessage是异步投递,不等待目标窗口处理完成,保证 UI 线程始终畅通;GetBestMove()内部已做深度限制(默认MAX_DEPTH = 2)和剪枝优化,单次调用耗时稳定在 50ms 内;若需更高难度,只需修改GameLogic.cpp中的MAX_DEPTH常量并重新编译,无需动 UI 代码。
2.4 资源文件(.rc)中的菜单与快捷键配置
Gomoku.rc定义了用户可见的交互入口,其结构直接影响二次开发扩展性:
IDR_MAINMENU MENU BEGIN POPUP "游戏(&G)" BEGIN MENUITEM "新游戏(&N)", IDM_NEWGAME MENUITEM "悔棋(&U)", IDM_UNDO MENUITEM SEPARATOR MENUITEM "人机对战(&C)", IDM_MODE_AI MENUITEM "双人对战(&P)", IDM_MODE_PVP MENUITEM SEPARATOR MENUITEM "退出(&X)", IDM_EXIT END END ACCELERATORS BEGIN "N", IDM_NEWGAME, VIRTKEY, CONTROL "U", IDM_UNDO, VIRTKEY, CONTROL VK_SPACE, IDM_UNDO, VIRTKEY "R", IDM_NEWGAME, VIRTKEY, CONTROL END注意:
VIRTKEY表示虚拟键码(如VK_SPACE),比 ASCII 码更可靠;CONTROL修饰符确保 Ctrl+N/Ctrl+U 不与浏览器快捷键冲突;菜单项 ID(如IDM_MODE_AI)必须与GameLogic.h中的枚举值严格一致,否则切换模式时gameMode变量不会更新。
3. 编译与运行:从零配置 VS 环境到首局对弈的完整流程
3.1 Visual Studio 版本与项目创建步骤(以 VS 2022 为例)
- 打开 VS 2022 → 「创建新项目」→ 搜索 “Win32 项目” → 选择「Win32 桌面应用程序」模板;
- 项目名称填
Gomoku,位置选空文件夹,取消勾选「为解决方案创建目录」; - 在「Win32 应用程序向导」中:
- 应用程序类型:选「Windows 应用程序」;
- 附加选项:务必取消勾选「预编译头」(此项目无大头文件,禁用可避免
stdafx.h相关报错); - 其他选项保持默认,点击「完成」;
- 删除向导生成的
Gomoku.cpp全部内容,粘贴项目源码;将GameLogic.h/.cpp、BoardRenderer.h/.cpp、Resource.h、Gomoku.rc复制到项目目录; - 在「解决方案资源管理器」中右键项目 → 「属性」→ 「配置属性」→ 「常规」→ 「字符集」改为「使用多字节字符集」(避免
TEXT()宏编译失败); - 右键
Gomoku.rc→ 「属性」→ 「配置属性」→ 「常规」→ 「排除于生成之外」设为「否」。
血泪经验:VS 2019+ 默认启用预编译头,若未取消,编译会报
fatal error C1010: 在查找预编译头时遇到意外的文件结尾。这不是代码错误,是工程配置问题——很多开发者在此卡住超过 1 小时。
3.2 必须修改的编译器设置与链接器参数
仅靠默认设置无法通过链接,需手动补全两处关键配置:
- 链接器 → 输入 → 附加依赖项:添加
gdi32.lib(GDI 绘图必需); - C/C++ → 预处理器 → 预处理器定义:追加
WIN32_LEAN_AND_MEAN(精简 Windows 头文件,避免winsock.h冲突)。
操作路径:右键项目 → 「属性」→ 左侧树形菜单逐级展开设置项。这两项漏配会导致CreateSolidBrush、TextOut等函数链接失败,错误提示为LNK2019: 无法解析的外部符号。
3.3 首次运行必现的三个现象及应对
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 窗口空白,无棋盘网格 | WM_PAINT中未调用BeginPaint/EndPaint,或BoardRenderer::DrawBoard()内部 GDI 对象未正确 SelectObject | 检查Gomoku.cpp中WM_PAINT分支,确认PAINTSTRUCT ps; HDC hdc = BeginPaint(hwnd, &ps);成对出现;在DrawBoard()开头加assert(hdc != NULL);快速定位 |
| 鼠标点击无反应,但菜单可用 | WM_LBUTTONDOWN消息未被WndProc捕获,常见于窗口类注册时lpfnWndProc指针传错 | 核对WinMain中RegisterClassEx(&wcex)前的wcex.lpfnWndProc = WndProc;是否拼写正确(易错为WndProce或WmdProc) |
| 悔棋(Ctrl+U)后棋子消失但状态未回退 | IDM_UNDO消息未在WndProc的WM_COMMAND分支中处理,或gameState.Undo()实现有 bug | 在WM_COMMAND分支添加case IDM_UNDO: gameState.Undo(); InvalidateRect(...); break;;检查GameLogic.cpp中Undo()是否同时回滚board[][]和moveHistory栈 |
提示:VS 调试时按
F5启动,若弹出「找不到 .pdb 文件」警告,忽略即可——此项目无调试信息生成需求,不影响功能。
3.4 验证运行成功的标志性行为
当出现以下三个连续动作,即证明环境配置与代码逻辑完全就绪:
- 窗口标题栏显示「五子棋 - 人机对战」,左上角有自定义 ICO 图标(来自
Gomoku.rc); - 鼠标悬停棋盘区域,光标自动变为手型(
LoadCursor(NULL, IDC_HAND)已在WM_SETCURSOR中设置); - 点击任意交叉点,黑色棋子立即绘制,随后 0.3 秒内白色棋子自动落下,且
IsWin()正确触发胜利弹窗。
此时你已站在可二次开发的坚实地基上——接下来所有改造,都基于这个稳定运行的基线。
4. 常见问题排查:那些让你怀疑 Win32 是玄学的 4 个瞬间
4.1 现象:棋盘网格线粗细不均,部分线条显示为虚线
原因:GDI 绘图时未设置画笔风格。Win32 默认画笔为PS_SOLID,但在高 DPI 缩放或某些显卡驱动下,MoveToEx/LineTo绘制斜线时若未指定PS_GEOMETRIC样式,会导致抗锯齿异常,视觉上呈现断续。
解决:在BoardRenderer.cpp的DrawBoard()函数中,创建画笔时强制指定几何样式:
HPEN hPen = CreatePen(PS_GEOMETRIC | PS_SOLID, 1, RGB(0, 0, 0)); // 关键:PS_GEOMETRIC HGDIOBJ oldPen = SelectObject(hdc, hPen); // ... 绘制网格代码 ... DeleteObject(hPen); // 必须释放,否则内存泄漏注意:
PS_GEOMETRIC告诉 GDI 将画笔视为几何对象(支持缩放、旋转),而非设备相关位图;DeleteObject必须成对出现,否则每重绘一次就泄露一个 GDI 句柄,100 次后窗口彻底无法刷新。
4.2 现象:切换「双人对战」模式后,电脑仍自动落子
原因:菜单点击事件IDM_MODE_PVP仅修改了gameMode变量,但未重置游戏状态,导致WM_COMMAND中ID_AI_MOVE消息仍被处理(因为gameState.IsGameOver()为 false)。
解决:在WM_COMMAND的IDM_MODE_PVP分支末尾,强制清空待处理的ID_AI_MOVE消息:
case IDM_MODE_PVP: gameMode = GAME_MODE_PVP; gameState.Reset(); // 重置棋盘与历史记录 InvalidateRect(hwnd, NULL, TRUE); // 清除可能已投递但未处理的 AI 消息 MSG msg; while (PeekMessage(&msg, hwnd, WM_COMMAND, WM_COMMAND, PM_REMOVE)) { if (msg.wParam == ID_AI_MOVE) continue; // 丢弃 TranslateMessage(&msg); DispatchMessage(&msg); } break;关键逻辑:
PeekMessage非阻塞扫描消息队列,PM_REMOVE参数将其移除;只过滤ID_AI_MOVE,保留其他WM_COMMAND(如IDM_UNDO)正常处理。
4.3 现象:高分屏(如 2560×1440)下棋盘严重变形,网格挤压成窄条
原因:BoardToGrid()坐标转换函数硬编码了棋盘边距(如MARGIN = 40),未适配 DPI 缩放。系统 DPI 设置为 125% 时,40 像素实际占用 50 物理像素,导致计算偏移。
解决:在WndProc的WM_CREATE中获取当前 DPI,并动态计算边距:
case WM_CREATE: { HDC hdc = GetDC(hwnd); int dpi = GetDeviceCaps(hdc, LOGPIXELSX); // 获取 X 轴 DPI ReleaseDC(hwnd, hdc); MARGIN = MulDiv(40, dpi, 96); // 以 96 DPI 为基准换算 break; }参数说明:
MulDiv(a,b,c)是 Win32 安全整数乘除宏,避免a*b/c整数溢出;96 是 Windows 默认 DPI 基准值;MARGIN需声明为全局变量或类成员,供BoardToGrid()调用。
4.4 现象:连续快速点击同一位置,生成多个棋子(覆盖绘制)
原因:IsValidMove()仅检查board[row][col] == EMPTY,但未考虑WM_LBUTTONDOWN可能在InvalidateRect重绘完成前被重复触发(尤其在 Debug 模式下)。
解决:增加客户端坐标去抖(debounce)机制,100ms 内相同坐标点击只响应一次:
static POINT lastClick = {-1, -1}; static DWORD lastClickTime = 0; case WM_LBUTTONDOWN: { POINT pt = { GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam) }; DWORD now = GetTickCount(); if (pt.x == lastClick.x && pt.y == lastClick.y && now - lastClickTime < 100) { break; // 去抖:100ms 内重复点击忽略 } lastClick = pt; lastClickTime = now; // ... 后续逻辑 ... }注意:
GetTickCount()返回毫秒数,精度足够;此方案比SetTimer更轻量,不引入额外消息循环。
5. 二次开发实战:从更换棋子样式到接入神经网络 AI 的三步改造
5.1 第一步:替换棋子渲染为 PNG 图片(支持透明背景)
原版DrawStone()使用Ellipse()绘制实心圆,若要加载 PNG 棋子(如黑子带阴影、白子带高光),需引入Gdiplus库。改造步骤如下:
- 工程配置:项目属性 → 「配置属性」→ 「常规」→ 「使用 Windows SDK 版本」设为最新(如 10.0.22621.0);
- 包含头文件:在
BoardRenderer.h顶部添加#include <gdiplus.h> using namespace Gdiplus; #pragma comment(lib, "gdiplus.lib") - 加载图片资源:在
BoardRenderer类中添加静态成员static Image* blackStoneImg = nullptr; static Image* whiteStoneImg = nullptr; static void LoadStones() { if (!blackStoneImg) blackStoneImg = Image::FromFile(L"black_stone.png"); if (!whiteStoneImg) whiteStoneImg = Image::FromFile(L"white_stone.png"); } - 重写
DrawStone():void DrawStone(HDC hdc, int row, int col, StoneColor color) { LoadStones(); // 首次调用时加载 Image* img = (color == BLACK) ? blackStoneImg : whiteStoneImg; if (img) { Graphics g(hdc); Rect destRect(x, y, STONE_SIZE, STONE_SIZE); // x,y 由 GridToBoard 计算 g.DrawImage(img, destRect); } }
关键点:
Image::FromFile路径为相对程序 exe 的路径;STONE_SIZE建议设为min(gridWidth, gridHeight) * 0.8,确保棋子不超出网格;PNG 必须为 32 位带 Alpha 通道,否则透明区域显示为黑色。
5.2 第二步:将 Minimax AI 替换为轻量级神经网络模型(ONNX Runtime)
若想体验现代 AI,可将GameLogic.cpp中的GetBestMove()替换为 ONNX 模型推理。我们选用已训练好的 15×15 五子棋策略网络(约 2MB),输入为 3 通道棋盘张量(黑子/白子/轮到谁下),输出为 225 维概率分布。
集成步骤:
- 下载 ONNX Runtime for Windows(CPU 版),将
onnxruntime.dll放入项目输出目录(Debug/Release); - 在
GameLogic.h中添加 ONNX 头文件引用:#include <onnxruntime_cxx_api.h> class GameLogic { private: Ort::Env env_; Ort::Session* session_ = nullptr; std::vector<const char*> inputNames_ = {"input"}; std::vector<const char*> outputNames_ = {"output"}; public: GameLogic(); // 构造函数加载模型 std::pair<int, int> GetBestMove() override; // 重写此函数 }; GetBestMove()实现核心逻辑:std::pair<int, int> GameLogic::GetBestMove() { // 构造输入张量:3x15x15 float 数组 std::array<int64_t, 3> inputShape{1, 3, 15 * 15}; std::vector<float> inputTensor(1 * 3 * 225, 0.0f); // 填充 inputTensor:channel 0=黑子, 1=白子, 2=当前玩家(1=黑, 0=白) // ... 填充逻辑(略)... Ort::Value inputT = Ort::Value::CreateTensor<float>( memoryInfo, inputTensor.data(), inputTensor.size(), inputShape.data(), inputShape.size()); // 推理 auto outputT = session_->Run(Ort::RunOptions{nullptr}, inputNames_.data(), &inputT, 1, outputNames_.data(), 1); // 解析输出,取概率最高位置 auto* probs = outputT[0].GetTensorData<float>(); int bestIdx = std::max_element(probs, probs + 225) - probs; return {bestIdx / 15, bestIdx % 15}; // 转为行列 }
注意:ONNX Runtime 初始化耗时较长(约 200ms),应在
GameLogic构造函数中完成session_创建,避免每次调用GetBestMove()都重复加载;模型文件(.onnx)需与 exe 同目录。
5.3 第三步:添加网络对战模块(TCP Socket 基础框架)
双人对战目前限于本地,若想拓展为局域网对战,只需在GameLogic中注入网络层。我们采用最简 TCP 协议:客户端发送MOVE:7,8,服务端广播给所有连接者。
最小可行改造:
- 新增
NetworkManager.h/.cpp,封装socket()/bind()/listen()/send()/recv(); GameLogic增加SendMove(int row, int col)和OnReceiveMove(int row, int col)接口;WndProc中WM_COMMAND的IDM_MODE_PVP分支改为启动NetworkManager并监听端口;- 鼠标点击后,先调用
SendMove(),再本地MakeMove();收到远程消息时,触发OnReceiveMove()并重绘。
协议设计原则:
- 消息以
\n结尾,避免粘包(recv()读到完整行才解析); - 服务端用
select()实现单线程多连接,不引入线程安全复杂度; - 客户端连接失败时,自动降级为本地双人模式(
gameMode = GAME_MODE_PVP_LOCAL)。
从那以后我每次接入新协议,都强制走一遍「断网测试」:拔掉网线,确认本地模式无缝接管;再插回网线,验证连接恢复后状态同步。这个习惯让我避开了 90% 的网络模块偶发故障。希望帮到你。
本文还有配套的精品资源,点击获取