news 2026/9/7 2:22:37

C++与Win32 GDI实现五子棋人机对战:从权值评分到搜索剪枝

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C++与Win32 GDI实现五子棋人机对战:从权值评分到搜索剪枝

简介:面向Visual Studio平台C#开发学习者的五子棋完整项目,包含人机对战与人人对战两种模式。项目基于Windows窗体和EasyX图形库实现,覆盖棋盘状态管理、合法落子判断、胜负检测及基础人机AI搜索思路,适合对游戏开发、事件驱动编程和算法实践感兴趣的读者。资源共49个文件,核心为5个C++源代码文件和1个头文件,附带VS解决方案工程配置,同时包含棋盘棋子图片素材、背景音效、调试文件与构建日志,压缩包整体约107MB,目录结构完整便于在VS中直接打开构建。已有2652人学习下载。通过这份资料,读者可拿到一套可编译运行的五子棋桌面程序,参考其界面绘制、落子交互、输赢判断及人机对战逻辑,也可基于工程扩展悔棋、存档、难度升级等功能,是学习WinForms项目组织与C++游戏开发的实用样例。 如果你正在用 Visual Studio 找一个既能练手、又能拿去当课程设计的小项目,五子棋(含人机对战)是再合适不过的选择。我这两天刚完整实现了一版,界面用 Win32 GDI 绘制,核心逻辑用 C++ 完成,AI 部分先采用权值评分法跑通,再优化到搜索剪枝,实测下来可以和身边朋友杀得有来有回。下面按需求、逻辑、AI、交互、避坑的顺序聊一聊完整实现思路,适合刚学完 C++ 语法想找项目练手的同学,也适合准备交课程设计的朋友。

1. 项目整体设计与技术选型

1.1 需求拆解与模块划分

最初列功能清单时,我只写了三个必须项:15x15 棋盘、黑白双方轮流落子、任意一方五连即赢。后来要加人机对战,需求就扩展成三类:棋盘逻辑负责保存棋局状态、判断落子是否合法、判断是否产生胜者;AI 决策负责替电脑选择落子位置,难度尽量够用;界面交互负责把棋盘和棋子画出来,接收鼠标点击,并提供新游戏、悔棋这类基础操作。按这个划分,三个模块之间能分开测试,边界也比较清晰。

我见过不少同学先画界面再写逻辑,结果鼠标事件一接进来,所有代码耦合在一起,调试时不知道是绘图问题还是逻辑问题。所以这里提前定下原则:逻辑层不依赖任何窗口句柄,界面层只负责展示和输入转发。这样后面还能写一个控制台测试版快速验证算法,也是我这次重点推荐的做法。

1.2 为什么选纯 C++ 加 GDI

技术选型上,我考虑过 C# WinForms、Qt 和 Unity。最后选择 Visual Studio 里最原生的 C++ 加 Win32/GDI 方案,理由是:第一,五子棋本身不复杂,用游戏引擎属于杀鸡用牛刀;第二,GDI 画网格、画实心圆已经足够,程序不依赖第三方库,拷到别的机器上只要装了 Visual Studio 或者有 Windows SDK 就能编译;第三,很多学校课程设计明确要求 C++,用纯 C++ 路线最稳。

C# 确实能更快完成界面,但如果你想深入理解消息循环和 GDI 绘图,Win32 窗口项目是很好的入门。何况五子棋需要的界面就只有横线、竖线、圆点,GDI 写起来并不繁琐。麻烦的地方只是 Win32 窗口的样板代码多,但只要封装成几个函数,后面基本一劳永逸。

1.3 VS 项目配置的几个细节

我用的是 Visual Studio 2022 社区版,新建项目时选“Windows 桌面应用程序”,语言选 C++,然后开始往里面填代码。Visual Studio 2019 的操作基本一样。这里有两个配置细节容易坑人:一是高 DPI 下 GDI 画出来的棋子和网格会发虚,建议在程序入口调用SetProcessDPIAware(),让画面按真实像素渲染;二是项目字符集默认 Unicode,写字符串时最好用TEXT宏包裹,否则后续遇到中文标题乱码会很难查。解决方案平台选 x86 还是 x64 影响不大,只要保持统一就行。

2. 棋盘逻辑与胜负判断的实现

2.1 棋盘数据模型

棋盘逻辑最关键的是选对数据结构。我用一个固定大小的二维数组表示棋盘:const int BOARD_SIZE = 15; int board[BOARD_SIZE][BOARD_SIZE];,0 表示空位,1 表示黑棋,2 表示白棋。为什么不用 vector 或者动态数组?因为棋盘永远是 15x15,固定数组性能更高,而且访问时不需要担心扩容和内存碎片。逻辑层所有函数都基于 board 数组操作,界面层重绘时也直接遍历它,结构非常清晰。

为了支持悔棋,我额外维护了一个落子记录:

struct Move { int row, col; }; std::vector<Move> moveHistory;

每次玩家或 AI 成功落子,就把 Move 推进 vector;悔棋时从 vector 取出最后一步,将 board 对应位置清零再 pop。这个设计虽然简单,但能让人机双方共用同一条撤销路径,不需要分别记录 AI 和玩家的历史。

2.2 落子与合法性检测

落子逻辑是所有操作入口,玩家点击和 AI 返回位置后,最终都走同一个函数。函数内部只做两件事:判断行列是否越界,判断目标位置是否为空。这里特别提醒,不要在外层再写一遍校验,否则同一套判断逻辑出现两处,以后改了其中一个忘了另一个,会产生非常隐蔽的 bug。统一入口还有一个好处:AI 落子前也会先用同样的校验,避免它偶尔生成一个重复坐标。

合法校验通过后,把当前玩家的棋子写入board[row][col],然后把当前玩家切换成对手。这里我建议用一个成员变量currentPlayer管理当前轮次,黑棋固定为 1,人机对战默认玩家先手,所以初始值是 1。切换时只要写currentPlayer = 3 - currentPlayer;即可,黑棋变白棋、白棋变黑棋,非常简洁。后续的新游戏函数只需把 currentPlayer 重置为 1,再清空 board 和 moveHistory。

2.3 四方向扫描的五连判定

胜负判断是逻辑层最容易写漏的地方。我采用的方案是:每下一子,只从落子点出发检查四个方向,而不是开局后全盘扫描。四个方向分别是水平、垂直、主对角线和副对角线。以水平方向为例,从落子点向左逐个检查连续同色棋子的数量,再向右逐个检查,两头数量加起来再加 1,就是从当前点出发在水平方向上的连子总数。只要任意方向的连子数达到 5,就判定当前玩家获胜。

方向数组我写成int dirs[4][2] = {{0,1},{1,0},{1,1},{1,-1}};,四个方向分别对应水平、垂直、主对角、副对角。每次扫描时用两个 for 循环向正负方向延伸。注意边界判断必须写在访问数组之前,防止越界。我之前一度漏写副对角线方向,导致斜线五连永远判断不出来,后来打印棋盘配合断点才定位到,这个坑要特别留意。

代码实现类似下面这样:

bool checkWin(int row, int col, int player) { int dirs[4][2] = {{0,1},{1,0},{1,1},{1,-1}}; for (auto &d : dirs) { int count = 1; int r, c; for (int step = 1; ; step++) { r = row + d[0] * step; c = col + d[1] * step; if (r < 0 || r >= BOARD_SIZE || c < 0 || c >= BOARD_SIZE || board[r][c] != player) break; count++; } for (int step = 1; ; step++) { r = row - d[0] * step; c = col - d[1] * step; if (r < 0 || r >= BOARD_SIZE || c < 0 || c >= BOARD_SIZE || board[r][c] != player) break; count++; } if (count >= 5) return true; } return false; }

这样写的好处是,判断胜负的代价只和落子点附近的棋子数量有关,不会随着棋盘变大而变慢。而如果全盘扫描,每步都要遍历 225 个位置,虽然五子棋棋盘小感觉不出来,但代码容易混进很多无意义的统计逻辑。我实际测试中,反复互搏几百轮,这个 checkWin 函数从没出现误判。

3. 人机对战 AI:从权值评分到搜索剪枝

3.1 权值评分法的核心思想

AI 部分是我花时间最多的环节。最初我直接写了一个极大极小搜索,效果虽然好,但思考时间太长,而且一旦搜索深度超过四层,编译后的程序几乎像卡死一样。后来我把方案简化为权值评分法:遍历棋盘上所有空位,给每个位置按潜在棋型打分,AI 直接选择分最高的位置。这个方法单步响应快,棋力对普通玩家足够,而且逻辑直白,特别适合课程设计阶段。

所谓打分,就是模拟把这个棋子落在空位上,然后看以这个点为中心延伸出去的棋型能形成多少威胁。威胁越大,分越高。这样 AI 会选择最有利于自己的位置,同时也能在改一版评分表后具备防守能力。

3.2 棋型评分表的设计

要给空位打分,必须先定义什么是棋型。五子棋常见的进攻棋型有连五、活四、冲四、活三、眠三、活二、眠二。以“活三”为例,表示三个连续棋子两端都没有被己方或对方棋子阻挡,下一手可以变成活四或冲四,威胁很大。评分表用递增的分数体现不同棋型的威胁程度:

棋型分数
连五100000
活四50000
冲四10000
活三5000
眠三1000
活二500
眠二100
死棋0

这张表不是绝对标准,而是经验值。如果你希望 AI 更激进,就把活三的分数从 5000 调高到 8000,它会更早开始做棋;如果你希望 AI 防守更紧,就把眠三分数调高,让它更早堵对方。我在调试时经常一局下完就改一次数字,改到满意为止。而且每次修改后最好和被修改前的版本各下几局,记录胜负和关键棋型,再决定留哪个参数。数值游戏玩到后面会发现,调参也是一种乐趣。

3.3 进攻与防守的综合决策

只有一张评分表还不够,因为五子棋是双人博弈,AI 既要进攻也要防守。我的处理方法是分别计算进攻分和防守分:进攻分模拟当前 AI 的棋子落在空位,按评分表打一次分;防守分模拟对手的棋子落在同样位置,再按评分表打一次分。最终得分等于进攻分加防守分乘一个 0.9 的防守系数。这样设计后,当两边都只有普通棋型时,AI 会优先发展自己;一旦发现对手有活三之类的高威胁棋型,防守分会迅速超过进攻分,AI 就会去堵。

具体实现时,我写了一个evaluatePoint函数,它接收行列和玩家编号,遍历四个方向统计连续同色棋子的数量,同时记录两端是被边界挡住还是被异色棋子挡住。根据连子数和阻挡状态选择对应分数累加。这个函数要稍微注意一个细节:统计时不要真的先把board[row][col]改成当前棋子,而是把当前点当作起点在虚拟方向上延伸。否则模拟完还要还原数组,一旦漏掉还原,后面搜索算法会积累错误状态,特别难查。

3.4 进阶优化:极大极小搜索

如果想让 AI 更强,可以在权值评分之上叠加搜索。我使用的折中方案是:先用权值法对整个棋盘空位排个序,只保留分数最高的前 8 到 10 个候选点,然后对这些候选点做两到三层的极大极小搜索。搜索中每一层选择对当前落子方最优的点,下一层则选择对对方最优的点,交替进行。alpha-beta 剪枝能把大量无意义的分支剪掉,实测在候选点限制下三层搜索大约几十毫秒,不会让玩家觉得卡。

这里建议搜索深度不要一上来就调 5 层。五子棋每层候选点即使只有十个,三层就有 1000 个节点,四层直接 1 万多个,不加剪枝照样吃力。如果只是想完成人机对战功能,权值评分法已经完全足够,搜索剪枝可以作为扩展项写到报告里。课程设计答辩时,能讲清楚权值表和防守系数,已经比大多数模板项目强很多。

4. 窗口绘制与交互处理

4.1 GDI 绘棋盘与棋子

界面部分我沿用 Win32 窗口的消息机制。WM_PAINT 消息里使用 GDI 绘图,棋盘由 15 条横线和 15 条纵线组成,线间距我用CELL_SIZE = 40像素,棋盘边缘留 25 像素,窗口客户区大小只要刚好放下 15 个格子加边距就行。画完线再画星位,也就是传统棋盘上那几个点,用实心小圆点即可。最后遍历 board 数组,把黑白棋用黑色和白色实心圆画出来。

绘制代码里有个容易忽略的坑:每次调用CreateSolidBrush创建画刷后,必须在用完时SelectObject换回旧画刷并DeleteObject删除画刷。很多人的程序一开始正常,玩了几盘后越画越卡,多半就是 GDI 句柄泄漏。我建议把棋盘和棋子的绘制分别封装成DrawBoardDrawPieces两个函数,WM_PAINT 里只调它们,既清晰又便于排查。

PAINTSTRUCT ps; HDC hdc = BeginPaint(hwnd, &ps); // 画网格 for (int i = 0; i < BOARD_SIZE; i++) { MoveToEx(hdc, MARGIN, MARGIN + i * CELL_SIZE, NULL); LineTo(hdc, MARGIN + (BOARD_SIZE - 1) * CELL_SIZE, MARGIN + i * CELL_SIZE); } for (int j = 0; j < BOARD_SIZE; j++) { MoveToEx(hdc, MARGIN + j * CELL_SIZE, MARGIN, NULL); LineTo(hdc, MARGIN + j * CELL_SIZE, MARGIN + (BOARD_SIZE - 1) * CELL_SIZE); } // 画棋子 for (int row = 0; row < BOARD_SIZE; row++) { for (int col = 0; col < BOARD_SIZE; col++) { if (board[row][col] == 1 || board[row][col] == 2) { HBRUSH brush = CreateSolidBrush(board[row][col] == 1 ? RGB(0, 0, 0) : RGB(255, 255, 255)); HGDIOBJ old = SelectObject(hdc, brush); Ellipse(hdc, col * CELL_SIZE + MARGIN - RADIUS, row * CELL_SIZE + MARGIN - RADIUS, col * CELL_SIZE + MARGIN + RADIUS, row * CELL_SIZE + MARGIN + RADIUS); SelectObject(hdc, old); DeleteObject(brush); } } } EndPaint(hwnd, &ps);

如果嫌每次创建画刷繁琐,也可以在窗口初始化时创建好黑刷和白刷存为全局变量,窗口销毁时再统一释放,性能会更好。第一次写 GDI 的人往往不习惯这种手工管理资源的模式,但养成好习惯后,程序能连续开一整天不崩。这里顺便提醒,如果使用双缓冲绘制,需要再创建一个内存 DC 和兼容位图,绘制完成后再一次性BitBlt到窗口,可以彻底解决棋盘闪烁问题。

4.2 鼠标点击坐标换算

鼠标点击通过 WM_LBUTTONDOWN 消息响应,参数 lParam 的低 16 位是 x 坐标,高 16 位是 y 坐标。换算成棋盘行列时,先用 x 减去左边距,再除以格子大小,同时加上半个格子大小做四舍五入。我用的公式是int col = (x - MARGIN + CELL_SIZE / 2) / CELL_SIZE;,行坐标同理。如果不做四舍五入,玩家点格子边缘时会出现落点偏移,感觉特别生硬。

得到行列后先调用isValidMove,合法则执行落子并调用InvalidateRect触发重绘。这里有一个很关键的消息处理思路:不要在鼠标消息里通过 Sleep 或死循环等待 AI 落子,那会让窗口消息队列阻塞,界面直接无响应。正确做法是落子后立即重绘,然后通过PostMessageSetTimer触发下一步 AI 计算。如果 AI 计算较快,也可以在鼠标消息里直接调用findBestMove并更新棋盘,但重量级搜索一定要换到其他时机去执行。

4.3 菜单命令与游戏状态管理

菜单在 Win32 里可以直接用资源文件定义,也可以用CreateMenu动态创建。我选择了最简单的方式:在窗口创建时用CreateWindowEx加上菜单句柄,菜单项包括“新游戏”和“悔棋”。新游戏命令触发ResetGame(),把 board 清零、moveHistory 清空、currentPlayer 重置为 1、gameOver 设为 false,最后重绘。悔棋命令稍微复杂,如果当前轮到 AI 思考,实际上玩家还没落子,这时候悔棋应该撤销 AI 的上一步和玩家的上一步,即连续弹出两段历史。

游戏状态管理我用了一个 boolgameOver和 boolisPlayerTurn。isPlayerTurn 为 true 时处理鼠标落子,为 false 时只允许 AI 动作。这个方法比用 currentPlayer 直接判断更直观:人机对战模式下,无限次点击棋盘不会导致提前落子,因为玩家只能在 isPlayerTurn 为 true 时落子,AI 落子完成后会再把控制权交回来。第一版我漏了 gameOver 判断,游戏结束后棋局还能继续变,在鼠标消息开头加上if (gameOver) return;就解决了。

5. 编译调试与避坑指南

5.1 环境配置上的三个坑

Visual Studio 本身也有一些让人头大的地方。先说最常见的控制台闪退:调试运行没问题,但直接双击 exe 会一闪而过,这是因为程序执行完就退出了。解决的办法是在入口函数末尾加system("pause")std::cin.get(),不过这只适合练习,正式程序不要用。如果项目连编译都过不了,抬头看一眼报错窗口,很多是字符集或 SDK 版本不匹配,重新安装对应版本的 Windows SDK 组件就行。

第二个坑是中文乱码。Win32 项目默认 Unicode,如果直接写const char*中文,中文会变成问号。用TEXT宏包裹字符串,或者把项目属性里的字符集改为“使用多字节字符集”,基本能解决。第三个坑是工具栏上的“生成”按钮不见了,这个可以在“视图-工具栏-标准”里找回来。如果遇到启动报错Microsoft.ServiceHub.Client.Controller,多半是 Visual Studio 安装损坏,重新运行安装程序选择“修复”会比完全卸载重装快很多,而且已安装组件和配置不会丢。

5.2 逻辑调试技巧

算法逻辑建议先在控制台工程里测。我给 board 写了一个printBoard函数,用 O、X、. 分别代表黑、白、空位,每次怀疑哪里不对就直接打印当前棋盘。这种土办法比单步断点更直观,尤其是 AI 评分这类递归函数,单步走不下去,打印反而是最快的定位方式。测试胜负判断时,手动在数组里摆一个横向或斜向五连,再运行 checkWin 看返回值,基本一轮就能确认方向数组有没有漏写。

检查权值评分函数时,最好把四个方向分别计算出的分数打印出来。如果某一方向对明显能成五连的位置给出 0 分,那多半是方向数组写错或阻挡判断写反。我遇到最多的问题就是在副对角线方向上写成 {1,1},导致正对角线扫描了两遍、副对角线根本没扫。这类 bug 靠肉眼看代码很难发现,打印方向评分立刻原形毕露。

5.3 实战心得:先控制台后界面

这次项目我实际开发顺序是先写控制台版,把棋盘逻辑和 AI 全部跑通,再建 Win32 窗口项目把逻辑层代码复制过去。虽然看上去多了一步,但效率反而更高。因为控制台里 printf 随便打,断点随便下,一旦进了窗口消息循环,每一次重绘、鼠标事件、菜单命令都会打断你的思绪,如果同时还要排查逻辑 bug,很容易一晚上都在跟窗口死磕。

另外强烈建议从头就使用 Visual Studio 自带的 Git 工具,改动评分表或防守系数前先提交一次版本。我调整 AI 强度时经常出现“改完还不如上一版”的情况,有版本记录可以随时回退,不用手动保存一堆备份文件。五子棋项目的核心难度并不在画界面,而在于 AI 评分表和边界条件。先把逻辑层打磨稳,界面只是最后的一层皮。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/7 2:22:27

基于SpringBoot的校园表白墙系统源码+文档+讲解视频

温馨提示&#xff1a;本人主页置顶文章(点我)开头有 CSDN 平台官方提供的学长联系方式的名片&#xff01; 温馨提示&#xff1a;本人主页置顶文章(点我)开头有 CSDN 平台官方提供的学长联系方式的名片&#xff01; 温馨提示&#xff1a;本人主页置顶文章(点我)开头有 CSDN 平台…

作者头像 李华
网站建设 2026/9/7 2:22:23

基于SpringBoot的汽车租赁系统源码+文档+讲解视频

温馨提示&#xff1a;本人主页置顶文章(点我)开头有 CSDN 平台官方提供的学长联系方式的名片&#xff01; 温馨提示&#xff1a;本人主页置顶文章(点我)开头有 CSDN 平台官方提供的学长联系方式的名片&#xff01; 温馨提示&#xff1a;本人主页置顶文章(点我)开头有 CSDN 平台…

作者头像 李华
网站建设 2026/9/7 2:18:55

嵌入式SPI协议实战避坑指南:时序、片选与多从机设计

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 2:17:58

《我看见的世界》:李飞飞与ImageNet引爆深度学习革命

简介&#xff1a;《我看见的世界》是知名AI学者李飞飞撰写的个人与科技交织之作&#xff0c;面向人工智能初学者、从业者以及对科技人文话题感兴趣的普通读者。书中以作者独特视角梳理AI的定义、发展历程与社会影响&#xff0c;并结合她作为终身学者赴美国众议院作证的里程碑经…

作者头像 李华
网站建设 2026/9/7 2:17:05

【信息科学与工程学】计算机科学与自动化——第一百五十九篇 前端领域中常见的核心算法与功能分类02

全部聚焦 CSS 领域,涵盖最新的 CSS 特性如三角函数、颜色函数、滚动驱动动画、视图过渡、文本平衡、形状、滤镜、混合模式、计数器、自定义属性、排版、网格高级、弹性盒子、滚动条、打印、分页、字体、变换、动画关键帧、过渡、环境变量、媒体查询、容器查询、层叠层、作用域…

作者头像 李华
网站建设 2026/9/7 2:16:49

WTL实战指南:从环境搭建到消息映射与控件封装的完整解析

简介&#xff1a;这是一份面向 Windows 桌面开发者的 WTL 教程合集与开发者指南&#xff0c;围绕如何用轻量级 C 库构建高效本地程序展开&#xff0c;内容涵盖环境搭建、窗口与控件操作、消息映射、事件处理、UI 设计、ATL COM 组件开发、国际化、性能优化与调试测试等主题。资…

作者头像 李华