Win32五子棋实战:消息循环、GDI绘图与非阻塞AI设计

发布时间:2026/10/24 9:07:08
Win32五子棋实战:消息循环、GDI绘图与非阻塞AI设计

简介:本资源是一份基于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 为例)

  1. 打开 VS 2022 → 「创建新项目」→ 搜索 “Win32 项目” → 选择「Win32 桌面应用程序」模板;
  2. 项目名称填Gomoku,位置选空文件夹,取消勾选「为解决方案创建目录」;
  3. 在「Win32 应用程序向导」中:
    • 应用程序类型:选「Windows 应用程序」;
    • 附加选项:务必取消勾选「预编译头」(此项目无大头文件,禁用可避免stdafx.h相关报错);
    • 其他选项保持默认,点击「完成」;
  4. 删除向导生成的Gomoku.cpp全部内容,粘贴项目源码;将GameLogic.h/.cpp、BoardRenderer.h/.cpp、Resource.h、Gomoku.rc复制到项目目录;
  5. 在「解决方案资源管理器」中右键项目 → 「属性」→ 「配置属性」→ 「常规」→ 「字符集」改为「使用多字节字符集」(避免TEXT()宏编译失败);
  6. 右键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 验证运行成功的标志性行为

当出现以下三个连续动作,即证明环境配置与代码逻辑完全就绪:

  1. 窗口标题栏显示「五子棋 - 人机对战」,左上角有自定义 ICO 图标(来自Gomoku.rc);
  2. 鼠标悬停棋盘区域,光标自动变为手型(LoadCursor(NULL, IDC_HAND)已在WM_SETCURSOR中设置);
  3. 点击任意交叉点,黑色棋子立即绘制,随后 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库。改造步骤如下:

  1. 工程配置:项目属性 → 「配置属性」→ 「常规」→ 「使用 Windows SDK 版本」设为最新(如 10.0.22621.0);
  2. 包含头文件:在BoardRenderer.h顶部添加
    #include <gdiplus.h> using namespace Gdiplus; #pragma comment(lib, "gdiplus.lib")
  3. 加载图片资源:在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"); }
  4. 重写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 维概率分布。

集成步骤:

  1. 下载 ONNX Runtime for Windows(CPU 版),将onnxruntime.dll放入项目输出目录(Debug/Release);
  2. 在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; // 重写此函数 };
  3. 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% 的网络模块偶发故障。希望帮到你。

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