简介:这份资源是国科大五子棋个人大作业的完整源码包,面向正在学习C++、需要完成课程设计或想通过小游戏项目巩固编程基础的学生。项目实现了人机对战模式,系统执黑、玩家执白,双方轮流落子,先连成横、竖或斜线五子及以上者获胜,涵盖胜负判定、棋盘绘制与简单AI决策等核心逻辑。压缩包共8个文件,约15KB,以4个C语言源文件为主体,另含头文件、配置文件、说明文档与README,分别承担棋盘与落子逻辑、AI走子策略、参数配置及项目说明等职责,结构紧凑、便于阅读与二次修改。目前已有109人学习下载。读者可从中获得一份可直接编译运行的课程作业参考,理解五子棋的规则建模、胜负判断与基础人机算法实现思路,并借助README快速梳理代码结构,适合作为C++入门练手或大作业排错与改进的对照材料。
1. 国科大五子棋大作业拆包:一份能跑能改的 C++ 实战底稿
如果你正在国科大读研一,选课列表里大概率躺着一门叫「程序设计」或类似名字的课,期末大作业要求用 C++ 写一个带图形界面的小游戏。五子棋几乎是每年出现频率最高的选题——规则简单、逻辑闭环、能塞进 AI 算法、还能顺手把 MFC 或 Qt 的界面框架练一遍。但真正动手时你会发现,从「知道五子棋怎么下」到「交出一个能编译、能演示、能答辩的工程」,中间隔着一堆没人告诉你的细节:棋盘怎么画不闪屏、鼠标点击怎么映射到交叉点、胜负判断怎么不写成八重循环、AI 估值函数怎么调参才不像随机落子。
这份wzq.zip是国科大 wcg 班第四份个人大作业的完整提交包,里面是一套用 C++ 实现的五子棋工程。它不是什么商业级产品,但胜在结构完整、逻辑清晰、注释到位,拿来当课程设计的底稿或者自学 C++ 图形编程的练手项目都合适。适合三类人:正在被大作业卡住需要参考实现的同学、想通过一个完整小项目巩固 C++ 面向对象和事件驱动编程的初学者、以及需要一份能快速改造成自己版本(换界面、换 AI、换规则)的开发者。下面从工程结构、编译运行、核心逻辑、AI 实现到踩坑排查,一层层拆开讲。
2. 工程结构与编译环境:先让代码在你机器上跑起来
2.1 解压后先看什么:目录树与文件职责
拿到wzq.zip后别急着双击 .sln 或 .vcxproj,先解压到一个纯英文路径下。中文路径在部分老版本 Visual Studio 和 MinGW 下会触发链接器找不到文件的玄学问题,血泪经验是路径里连空格都别带。解压后典型结构如下(不同年份提交可能略有差异,但核心文件跑不掉):
| 文件/目录 | 作用 | 是否可改 |
|---|---|---|
wzq.sln/wzq.vcxproj | Visual Studio 解决方案与工程文件 | 换 VS 版本时需重新生成 |
main.cpp | 程序入口,初始化窗口与消息循环 | 可改窗口标题、尺寸 |
Board.h/Board.cpp | 棋盘数据结构、落子、胜负判断 | 核心逻辑,建议读懂再改 |
AI.h/AI.cpp | 电脑落子决策,估值与搜索 | 调参主战场 |
Render.h/Render.cpp | 棋盘绘制、棋子绘制、鼠标交互 | 换皮肤改这里 |
resource.h/.rc | 菜单、图标等资源定义 | 一般不动 |
res/或images/ | 棋盘背景、棋子贴图 | 可替换素材 |
如果解压后只有.cpp和.h没有工程文件,说明提交时只打包了源码。这种情况你需要自己新建一个「Windows 桌面应用程序」或「控制台应用 + 图形库」工程,把源文件拖进去。常见做法是新建空项目后手动添加现有项,再在项目属性里配置字符集为「使用多字节字符集」——MFC 和 Win32 API 混用时这个设置不对会报一堆LPCWSTR类型不匹配的错误。
2.2 编译环境选型:VS 版本与字符集配置
这份作业大概率基于 Win32 API 或 MFC 编写,推荐用 Visual Studio 2019 或 2022 的「使用 C++ 的桌面开发」工作负载。安装时勾选「MSVC v142 生成工具」和「Windows 10 SDK」。如果你只有 VS Code,也不是不能跑,但需要自己配 MinGW 或 MSVC 命令行工具链,且图形部分依赖 Windows.h,跨平台编译会直接失败——所以别想着在 Mac 或 Linux 上原生跑,虚拟机或远程桌面才是正路。
打开解决方案后,先做三件事:
- 右键项目 → 属性 → 常规 → 字符集,确认与源码匹配。源码里用
TCHAR、_T("")宏的选「使用 Unicode 字符集」;直接用char*、MessageBoxA的选「使用多字节字符集」。 - 属性 → C/C++ → 语言 → C++ 语言标准,选
ISO C++14或ISO C++17。老代码用C++11也能过,但别选「默认(C++14)」以外的预览标准。 - 属性 → 链接器 → 系统 → 子系统,图形程序选
Windows (/SUBSYSTEM:WINDOWS),控制台调试版选Console (/SUBSYSTEM:CONSOLE)。
配置完按Ctrl+Shift+B生成。如果报fatal error C1083: 无法打开包括文件: "afxwin.h",说明项目用了 MFC 但你没装 MFC 组件。打开 Visual Studio Installer → 修改 → 单个组件 → 搜索「MFC」→ 勾选「适用于最新 v143 生成工具的 C++ MFC」→ 安装后重启 VS。
2.3 首次运行与调试入口
编译通过后按F5启动调试。正常情况会弹出一个固定尺寸的窗口,显示棋盘网格和「黑棋先手」之类的提示。如果窗口一闪而过,检查main.cpp里WinMain的消息循环是否被提前return,或者子系统设成了 Console 但入口写的是WinMain——这俩不匹配时链接器会报LNK2019,但偶尔被忽略后运行时直接退出。
调试时建议在Board::placeStone和AI::getMove两处下断点,观察每次落子后棋盘数组的变化。棋盘通常用二维int数组表示,0 空、1 黑、2 白。鼠标点击坐标转棋盘索引的公式一般是:
// 假设棋盘左上角原点 (originX, originY),格子边长 cellSize int col = (mouseX - originX + cellSize / 2) / cellSize; int row = (mouseY - originY + cellSize / 2) / cellSize; // 边界检查,防止数组越界 if (row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE) { // 处理落子 }这段逻辑说明:加cellSize/2是为了让点击落在交叉点附近而非格子中心也能正确映射,这是五子棋交互和普通网格游戏最大的区别。参数originX/originY取决于你绘制棋盘时的起始偏移,改窗口大小时这两个值必须同步改,否则点击位置会整体偏移。
3. 核心逻辑拆解:棋盘、胜负与落子合法性
3.1 棋盘数据结构与落子合法性检查
五子棋的棋盘状态用二维数组就够了,但「合法落子」不只是「该位置为空」。正式规则里还有禁手(黑棋三三、四四、长连禁手),不过课程作业通常简化处理,只判空和是否在棋盘内。下面是一个典型的落子函数骨架:
// Board.h const int BOARD_SIZE = 15; class Board { public: int grid[BOARD_SIZE][BOARD_SIZE]; // 0空 1黑 2白 Board() { memset(grid, 0, sizeof(grid)); } bool isValidMove(int row, int col) const { if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) return false; return grid[row][col] == 0; } bool placeStone(int row, int col, int player) { if (!isValidMove(row, col)) return false; grid[row][col] = player; return true; } };逻辑说明:isValidMove先做边界检查再做空位检查,顺序不能反——先查grid[row][col]再查边界的话,越界访问会直接崩溃。参数player传 1 或 2,对应黑白。常见错误是把memset写成memset(grid, 0, BOARD_SIZE * BOARD_SIZE),漏了sizeof(int),结果只清了前 56 个字节,后面全是垃圾值,棋盘上会莫名出现棋子。正确写法是sizeof(grid)或BOARD_SIZE * BOARD_SIZE * sizeof(int)。
3.2 胜负判断:从八方向扫描到计数剪枝
胜负判断是五子棋最核心也最容易写丑的部分。新手常写成八个方向各一个循环,每个循环里再套一个计数循环,代码冗长且容易漏方向。更清爽的做法是只朝四个轴向的正反方向扫,每个方向统计连续同色棋子数:
bool Board::checkWin(int row, int col, int player) const { // 四个方向:横、竖、主对角线、副对角线 const int dirs[4][2] = {{0,1}, {1,0}, {1,1}, {1,-1}}; for (int d = 0; d < 4; ++d) { int count = 1; // 当前落子本身算一个 // 正方向延伸 for (int step = 1; step < 5; ++step) { int r = row + dirs[d][0] * step; int c = col + dirs[d][1] * step; if (r < 0 || r >= BOARD_SIZE || c < 0 || c >= BOARD_SIZE) break; if (grid[r][c] != player) break; ++count; } // 反方向延伸 for (int step = 1; step < 5; ++step) { int r = row - dirs[d][0] * step; int c = col - dirs[d][1] * step; if (r < 0 || r >= BOARD_SIZE || c < 0 || c >= BOARD_SIZE) break; if (grid[r][c] != player) break; ++count; } if (count >= 5) return true; } return false; }逻辑说明:dirs数组存了四个轴的方向向量,每个轴只朝正反两个方向各扫最多 4 步,总检查次数是 4×8=32 次,比全盘扫描快一个数量级。参数step < 5是因为五子棋连成五个就赢,从当前子出发最多再延伸四个。注意副对角线方向向量是{1,-1},即行增列减,写反了会变成主对角线,导致斜向五连判不出来。这个函数在每次落子后调用一次即可,不需要每帧都跑。
3.3 平局判定与棋盘满盘处理
棋盘 15×15 共 225 个交叉点,理论上最多下 225 手。平局判定很简单:落子后如果checkWin返回 false,且棋盘上没有空位,就是平局。但实际写的时候别每次遍历整个数组去数空位,用一个计数器moveCount在placeStone里自增,等于BOARD_SIZE * BOARD_SIZE时直接判平局。这个优化在 AI 搜索里尤其重要,因为搜索树展开时会频繁调用状态判断,O(n²) 的遍历会把搜索速度拖垮。
另外注意:有些实现会在棋盘满之前就出现「双方都无法五连」的死局,但严格五子棋规则里只要棋盘没满就继续下,所以课程作业按满盘判平局即可,不用做更复杂的和棋检测。
4. AI 落子决策:估值函数与搜索深度调参
4.1 估值函数设计:棋型分值与权重表
AI 部分通常是作业的加分项,也是最能拉开差距的地方。最简单的 AI 是随机落子,稍微像样点的是基于棋型评分的贪心算法,再往上才是极小化极大搜索加 Alpha-Beta 剪枝。这份作业里的 AI 大概率是贪心或浅层搜索,核心是一个估值函数:给每种棋型打分,然后选分数最高的空位落子。
常见棋型分值表如下(以当前玩家视角):
| 棋型 | 示例(1 为己方,0 为空) | 分值 |
|---|---|---|
| 成五 | 11111 | 100000 |
| 活四 | 011110 | 10000 |
| 冲四 | 011112 / 10111 | 1000 |
| 活三 | 011100 / 001110 | 1000 |
| 眠三 | 001112 / 010112 | 100 |
| 活二 | 001100 | 100 |
| 眠二 | 000112 | 10 |
估值函数遍历棋盘上每个空位,假设己方落子后扫描四个方向,统计形成的棋型并累加分数;再假设对方落子后同样统计,用己方分数减去对方分数乘以一个防守系数(通常 0.8~1.2),得到该位置的净收益。防守系数调高,AI 更倾向堵对方;调低,AI 更倾向自己进攻。这个参数没有理论最优值,得根据实际对局效果微调。
4.2 搜索深度与性能平衡:Alpha-Beta 剪枝实战
如果只做一层贪心,AI 很容易被「双三」「四三」这类组合杀击败,因为它看不到两步之后的威胁。加一层搜索就能明显改善:对每个候选落子,模拟落子后递归调用估值函数评估对方最佳回应,取最小值作为该落子的实际得分。这就是极小化极大搜索的雏形。
// 简化的 Alpha-Beta 搜索框架 int alphaBeta(Board& board, int depth, int alpha, int beta, bool isMax) { if (depth == 0 || board.isGameOver()) { return evaluate(board); // 叶子节点返回估值 } if (isMax) { int best = -INF; for (auto& move : board.getCandidateMoves()) { board.placeStone(move.row, move.col, AI_PLAYER); best = std::max(best, alphaBeta(board, depth - 1, alpha, beta, false)); board.undoStone(move.row, move.col); alpha = std::max(alpha, best); if (beta <= alpha) break; // 剪枝 } return best; } else { int best = INF; for (auto& move : board.getCandidateMoves()) { board.placeStone(move.row, move.col, HUMAN_PLAYER); best = std::min(best, alphaBeta(board, depth - 1, alpha, beta, true)); board.undoStone(move.row, move.col); beta = std::min(beta, best); if (beta <= alpha) break; // 剪枝 } return best; } }逻辑说明:depth控制搜索层数,课程作业设 2~4 层足够,再深会卡顿。alpha和beta是剪枝边界,当某一分支的估值已经不可能影响最终决策时直接跳过,能把搜索节点数砍掉一半以上。getCandidateMoves不要返回所有空位,只返回已有棋子周围两格内的空位,这样候选数从 200 多降到 20 以内,速度提升立竿见影。undoStone是回溯的关键,忘了写会导致棋盘状态污染,AI 越下越乱。
4.3 候选点生成与搜索顺序优化
候选点生成策略直接影响 AI 的棋力和响应速度。全盘扫描 225 个空位再逐个估值,在 depth=4 时计算量是 225⁴ 级别,普通笔记本要跑好几秒。实际做法是只考虑「热区」:距离任意已有棋子不超过 2 格的空位。这样开局候选少,中盘最多也就 30~40 个,配合 Alpha-Beta 剪枝,depth=4 能在 100 毫秒内出结果。
搜索顺序也有讲究:先搜估值高的点,能让剪枝更早生效。常见做法是先用浅层估值给候选点排序,再按顺序递归。这个优化不改变最终结果,但能把搜索时间再压 30%~50%。如果你发现 AI 落子后界面卡住不动,八成是候选点没做热区过滤,或者搜索深度设成了 6 以上。
5. 避坑与排查:编译、运行、AI 三类高频翻车现场
5.1 编译报错:字符集与库依赖不匹配
现象:满屏LNK2019: 无法解析的外部符号,或者C2664: 无法将参数从 const char* 转换为 LPCWSTR。 原因:项目字符集设置与源码字符串宏不匹配。源码用MessageBox(hwnd, "提示", "标题", MB_OK)这种窄字符串,但项目设成了 Unicode 字符集,API 实际期望宽字符串。 解决:要么把项目属性改成「使用多字节字符集」,要么把源码里所有字符串字面量用_T("")包起来。推荐后者,兼容性更好。改完记得清理解决方案再重新生成,否则增量编译可能残留旧目标文件。
5.2 运行时崩溃:数组越界与空指针
现象:程序启动后点几下棋盘就闪退,或者 AI 思考时直接卡死。 原因:鼠标坐标转棋盘索引时没做边界检查,点击棋盘边缘外侧导致grid[row][col]越界;或者 AI 搜索时候选点列表为空(棋盘已满或热区计算错误),取moves[0]时访问空数组。 解决:在坐标转换后加if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) return;;在 AI 取候选点前加if (moves.empty()) return fallbackMove();,fallback 可以返回第一个空位或直接认输。调试时用_CrtCheckMemory()或 AddressSanitizer 能快速定位越界位置。
5.3 AI 行为异常:估值反转与防守系数失控
现象:AI 不仅不堵玩家的四连,反而往无关位置落子;或者 AI 一直模仿玩家落子,玩家下哪它下哪。 原因:估值函数里己方和对方的分数符号搞反了,把对方棋型算成了正分;或者防守系数设成了负数,导致 AI 把堵对方当成负收益。 解决:在估值函数里打印每个候选点的myScore和oppScore,观察数值是否符合直觉。正常情况下一旦玩家形成活三,AI 在对应位置的oppScore应该显著高于其他点。防守系数建议从 1.0 开始调,每次增减 0.1,观察 AI 进攻性变化。
5.4 界面闪烁与重绘问题
现象:落子后棋盘闪烁一下,或者棋子显示不全、有残影。 原因:在WM_PAINT里直接绘制所有内容,没有用双缓冲;或者落子后只重绘了棋子区域,没重绘背景。 解决:在WM_PAINT里先把所有内容画到内存 DC,再一次性BitBlt到窗口 DC。这是 Win32 图形编程的标准双缓冲套路,能彻底消除闪烁。如果用的是 MFC,在OnDraw里用CDC兼容 DC 同样处理。别在鼠标事件里直接调Invalidate后立刻UpdateWindow,那样会强制同步重绘,反而更容易闪。
5.5 存档与复盘功能的数据一致性
现象:悔棋后 AI 的搜索状态没同步,导致 AI 落子位置错乱;或者存档读档后棋盘状态对但胜负判断失效。 原因:悔棋只改了棋盘数组,没恢复moveCount或历史记录栈;存档时只存了棋盘没存当前轮到谁。 解决:把棋盘状态、当前玩家、落子历史打包成一个GameState结构体,悔棋和存档都操作这个结构体,保证原子性。读档后重新调用一次checkWin全盘扫描,别依赖存档时的胜负标记,因为那个标记可能因为版本差异失效。
6. 从能跑到能交:改造、验证与答辩技巧
把工程跑起来只是第一步,真要交出一份不心虚的作业,还得做几轮改造和验证。我一般会按下面这个顺序过一遍:
第一步,换一套自己的界面素材。把res/里的棋盘背景和棋子贴图换成自己找的或画的,窗口标题改成自己的学号姓名。这一步花不了半小时,但能让答辩时老师一眼看出你不是直接拷贝的。改图时注意尺寸和原图保持一致,否则绘制坐标要跟着调。
第二步,给 AI 加一个难度选择菜单。简单难度用 depth=1 贪心,中等用 depth=2,困难用 depth=4 加 Alpha-Beta。菜单项在resource.h里加 ID,在WndProc里处理WM_COMMAND消息切换全局变量g_difficulty。这个改动代码量不大,但演示时能体现你对搜索深度和性能关系的理解,答辩加分项。
第三步,写一个自动对局测试。让 AI 自己跟自己下 100 盘,统计先手胜率、平均步数、最长对局。如果先手胜率超过 80%,说明估值函数防守不足;如果平均步数低于 20,说明有一方存在明显漏洞。这个测试能帮你发现手动对局时很难触发的边界 bug,比如双三判定、长连处理。
第四步,准备答辩演示脚本。别现场随机下,提前设计好几个局面:一个展示 AI 堵活三、一个展示 AI 做双三、一个展示悔棋和存档。每个局面手动摆好棋子后让 AI 接手,观察它的落子是否符合预期。如果某个局面 AI 表现不对,回到估值函数调权重,别临时改搜索深度——那会引入新的不确定性。
最后说一个我自己的习惯:每次改完 AI 参数,我都会用同一组固定局面跑一遍回归测试,记录 AI 的落子坐标。如果某次改动后落子位置变了,先别急着说「变强了」,很可能是估值函数的某个分支写反了。从那以后我每次调参都强制走一遍固定局面回归,省得答辩前夜发现 AI 突然变傻。希望帮到你。
本文还有配套的精品资源,点击获取