简介:这是一份基于C++实现的五子棋游戏源码,面向具备一定C++基础、希望深入理解博弈算法与规则实现的开发者。项目支持人机对战与人人对战,电脑AI具备一定智能水平,并完整实现了先手三三、四四禁手及长连判负规则,适合作为算法设计与面向对象编程的实战练习素材。压缩包共22个文件,约681KB,以cpp源文件与h头文件为核心,分别承载棋盘逻辑、AI决策与胜负判定等模块,另含obj、pdb、ilk等编译中间文件及exe可执行程序,方便直接运行与调试。资源已有1335人学习下载。通过研读源码,读者可掌握二维数组表示棋盘、Minimax结合Alpha-Beta剪枝的搜索策略、评价函数设计以及禁手规则的检查逻辑,同时了解图形界面事件处理与工程编译部署流程,为后续游戏开发与算法进阶打下基础。
1. 五子棋 AI 与禁手规则:为什么你的第一个版本总在长连上翻车
写一个能下五子棋的程序不难,难的是让它下得像个懂规则的人。很多人第一次动手,棋盘画好了、落子逻辑通了、甚至加了个基于评分的简单 AI,结果一测就发现两个致命问题:先手在特定局面下走出长连被判负,AI 却浑然不觉;或者 AI 只顾自己连五,完全不管对手已经三三、四四成杀。这两个坑,本质上都指向同一件事——你写的不是五子棋,而是一个“连五就赢”的简化模型。
带 AI 且先手禁手的五子棋源码,核心要解决三件事:棋盘状态的高效表示、禁手规则的准确判定、以及 AI 在规则约束下的搜索与评估。它适合已经会 C++ 基础语法、想通过一个完整项目打通“规则引擎 + 搜索算法 + 评估函数”这条链路的开发者。你不需要先成为棋类 AI 专家,但需要愿意把禁手那套判定逻辑一行行抠清楚,因为后面 AI 的每一步决策,都建立在它之上。
2. 棋盘表示与禁手判定:先把规则引擎写对,再谈 AI
2.1 用一维数组还是二维数组:选型背后的取舍
棋盘表示看似简单,但选错了后面会一直别扭。常见做法有两种:二维数组int board[15][15]和一维数组int board[225]。二维数组直观,调试时打印出来一眼就能看懂局面;一维数组在做方向偏移和缓存友好性上更顺,尤其是 AI 搜索时频繁访问相邻点位,一维加偏移量的写法能少一层索引计算。
我一般会选一维数组,但保留一个二维打印函数用于调试。原因是禁手判定和 AI 评估都需要沿四个方向扫描,一维数组配合方向偏移数组写起来更统一。下面是一个最小可用的棋盘定义:
#include <array> #include <cstring> constexpr int SIZE = 15; constexpr int DIRS[4][2] = {{0,1},{1,0},{1,1},{1,-1}}; // 横、竖、正斜、反斜 struct Board { std::array<int, SIZE * SIZE> cells{}; // 0空 1黑 2白 int moveCount = 0; int& at(int r, int c) { return cells[r * SIZE + c]; } int at(int r, int c) const { return cells[r * SIZE + c]; } bool inBounds(int r, int c) const { return r >= 0 && r < SIZE && c >= 0 && c < SIZE; } };这段代码里DIRS的顺序很重要,后面禁手判定和连子计数都依赖它。at提供两个版本,const 版本给只读评估函数用,非 const 版本给落子用。moveCount用来判断是否为空盘或开局阶段,某些禁手判定在盘面棋子很少时可以跳过,避免误判。
2.2 禁手判定的三个核心:三三、四四、长连
先手禁手规则里,黑棋不能走三三、四四和长连。白棋无禁手。判定顺序建议是:先判长连,再判四四,最后判三三。因为长连优先级最高,一旦形成长连直接判负,不需要再算其他。
长连判定最简单:落子后沿四个方向数连续同色棋子,任一方向达到 6 个及以上即为长连。注意是“连续”,中间不能隔空。
四四判定稍复杂:落子后,统计该点形成的“四”的数量。这里的“四”指的是再下一子就能成五的棋型。一个方向可能同时形成多个四,比如活四算一个四,但冲四也算一个四。如果总数大于等于 2,就是四四禁手。
三三判定最容易写错:统计该点形成的“活三”数量。活三的定义是再下一子能变成活四的棋型。关键点在于,有些三看起来是活三,但因为边界或对方棋子阻挡,实际上只能变成冲四,这种不算活三。很多初版代码在这里翻车,把眠三当成活三,导致禁手误判。
下面是一个方向扫描的辅助函数,用来数连续同色棋子:
// 从(r,c)沿方向(dr,dc)数连续同色棋子,不包括(r,c)本身 int countDir(const Board& b, int r, int c, int dr, int dc, int color) { int cnt = 0; int nr = r + dr, nc = c + dc; while (b.inBounds(nr, nc) && b.at(nr, nc) == color) { ++cnt; nr += dr; nc += dc; } return cnt; }这个函数是后面所有棋型识别的基础。参数color传当前落子方颜色,dr/dc来自DIRS。注意它不检查起点本身,因为起点是刚落的子,调用方已经知道颜色。
2.3 活三与眠三的区分:一个容易写错的边界
活三的严格定义:落子后形成的三,在两端至少有一端能继续延伸成活四。换句话说,你需要检查这个三的两端是否被堵死。如果一端被对方棋子或边界堵住,另一端虽然空但延伸后只能形成冲四,那它就不是活三。
实操中我一般写一个isOpenThree函数,先找到这个三的两端空位,然后模拟在空位落子,看是否能形成活四。活四的定义是两端都空、再下一子能成五。这个模拟过程不需要真的修改棋盘,可以用临时变量或复制一个小范围状态来判断。
// 判断在(r,c)落color后,沿(dr,dc)方向是否形成活三 bool isOpenThree(const Board& b, int r, int c, int dr, int dc, int color) { // 简化版:统计该方向连续子数及两端空位情况 int left = countDir(b, r, c, -dr, -dc, color); int right = countDir(b, r, c, dr, dc, color); int total = left + right + 1; if (total != 3) return false; // 检查两端是否至少有一端能延伸 int lr = r - (left + 1) * dr, lc = c - (left + 1) * dc; int rr = r + (right + 1) * dr, rc = c + (right + 1) * dc; bool leftOpen = b.inBounds(lr, lc) && b.at(lr, lc) == 0; bool rightOpen = b.inBounds(rr, rc) && b.at(rr, rc) == 0; return leftOpen || rightOpen; }这段代码是简化版,真实判定还需要考虑“跳三”的情况,即三中间有空位。完整实现会更长,但思路一致:先定位棋型,再模拟延伸,最后判断延伸结果是否满足活四条件。参数dr/dc决定扫描方向,color是落子方颜色。注意total != 3直接返回 false,因为只有恰好三个连续子才可能是三。
2.4 把禁手判定接进落子流程
禁手判定不能只在 AI 里做,玩家落子时也要做。常见做法是写一个isForbidden(Board& b, int r, int c)函数,返回该点对黑棋是否禁手。流程是:先临时落子,然后依次判长连、四四、三三,最后撤销落子。撤销这一步很关键,否则棋盘状态会被污染。
bool isForbidden(Board& b, int r, int c) { if (b.at(r, c) != 0) return false; b.at(r, c) = 1; // 假设黑棋 bool forbidden = false; // 长连 for (auto& d : DIRS) { if (countDir(b, r, c, d[0], d[1], 1) + countDir(b, r, c, -d[0], -d[1], 1) + 1 >= 6) { forbidden = true; break; } } // 四四和三三判定略,逻辑类似,统计数量 b.at(r, c) = 0; // 撤销 return forbidden; }参数说明:b是棋盘引用,r/c是落子点。临时落子后必须撤销,这是血泪经验,早期我忘了撤销,导致 AI 搜索时棋盘状态错乱,表现就是 AI 突然下出莫名其妙的位置。DIRS四个方向都要扫,不能只扫两个,因为斜向也可能形成长连。
3. AI 搜索与评估:在禁手约束下找最优解
3.1 极小化极大搜索的骨架与深度选择
五子棋 AI 的经典做法是极小化极大搜索加 Alpha-Beta 剪枝。搜索深度一般设 4 到 6 层,再深了在普通机器上会明显卡顿。深度 4 能覆盖两步攻防,深度 6 能覆盖三步,但分支因子大,需要配合候选点筛选。
候选点筛选是提速的关键:不要对棋盘上所有空位都搜索,只考虑已有棋子周围两格内的空位。这个范围能覆盖绝大多数有意义的落子,同时把分支数从 200 多降到 20 到 40。
std::vector<std::pair<int,int>> genMoves(const Board& b) { std::vector<std::pair<int,int>> moves; std::array<bool, SIZE*SIZE> used{}; for (int r = 0; r < SIZE; ++r) { for (int c = 0; c < SIZE; ++c) { if (b.at(r, c) == 0) continue; for (int dr = -2; dr <= 2; ++dr) { for (int dc = -2; dc <= 2; ++dc) { int nr = r + dr, nc = c + dc; if (!b.inBounds(nr, nc) || b.at(nr, nc) != 0) continue; int idx = nr * SIZE + nc; if (!used[idx]) { used[idx] = true; moves.emplace_back(nr, nc); } } } } } return moves; }genMoves返回候选落子列表,used数组去重。范围参数-2到2是经验值,再大收益不明显,再小可能漏掉关键防守点。如果盘面为空,需要特判返回中心点。
3.2 评估函数:棋型分值怎么定才不玄学
评估函数决定 AI 的“棋感”。常见做法是给不同棋型打分:成五最高,活四次之,冲四再次,活三、眠三、活二依次递减。防守分和进攻分要分开算,最后加权求和。
一个可用的分值表:
| 棋型 | 分值 |
|---|---|
| 成五 | 100000 |
| 活四 | 10000 |
| 冲四 | 1000 |
| 活三 | 1000 |
| 眠三 | 100 |
| 活二 | 100 |
| 眠二 | 10 |
注意活三和冲四分值接近,因为两者都能迫使对方回应。实际调参时,如果 AI 太激进,就提高防守权重;如果太保守,就提高进攻权重。这个表不是固定的,需要根据实测调整。
int evaluate(const Board& b, int color) { int score = 0; for (int r = 0; r < SIZE; ++r) { for (int c = 0; c < SIZE; ++c) { if (b.at(r, c) != color) continue; for (auto& d : DIRS) { // 统计该点沿方向的棋型并累加分值 // 具体棋型识别略,逻辑与禁手判定中的扫描类似 } } } return score; }评估函数里最容易翻车的是重复计分:同一个棋型被多个起点重复统计。解决办法是只从棋型的起始点开始统计,或者用方向上的“已访问”标记。我一般会在扫描时判断前一个位置是否同色,如果是就跳过,保证每个棋型只算一次。
3.3 把禁手判定嵌进搜索:AI 不能走出禁手
AI 搜索时,如果当前是黑棋且某候选点是禁手,必须直接跳过,不能让它进入评估。这一步要在生成候选点之后就过滤掉,而不是等到评估阶段再扣分,因为禁手是直接判负,不是分值高低的问题。
std::vector<std::pair<int,int>> legalMoves(Board& b, int color) { auto moves = genMoves(b); if (color != 1) return moves; // 白棋无禁手 std::vector<std::pair<int,int>> filtered; for (auto& m : moves) { if (!isForbidden(b, m.first, m.second)) { filtered.push_back(m); } } return filtered; }legalMoves对黑棋过滤禁手点,白棋直接返回。注意isForbidden内部会临时落子再撤销,所以传入的b必须是非 const 引用。这个函数在搜索树的每一层都会被调用,性能敏感,可以考虑缓存禁手判定结果,但初版先保证正确性。
4. 避坑与排查:禁手和 AI 联调时最容易翻车的五个点
4.1 禁手误判:活三被当成眠三,AI 疯狂走禁手
现象:AI 执黑时频繁走出三三或四四,被判定禁手后直接判负。原因:活三判定函数没有正确处理跳三和边界情况,把一些实际是活三的棋型判成了眠三,导致禁手漏判。解决:写一组固定的测试局面,每个局面手动标注是否为禁手,跑单元测试。重点覆盖跳三、边界三、以及被对方棋子部分阻挡的三。
4.2 搜索深度增加后 AI 变慢:候选点没有剪枝
现象:深度从 4 调到 6 后,AI 思考时间从不到一秒涨到十几秒。原因:候选点生成没有限制范围,或者 Alpha-Beta 剪枝没有正确实现,导致搜索树爆炸。解决:确认genMoves只返回已有棋子周围两格内的空位;检查 Alpha-Beta 的剪枝条件,确保在 beta <= alpha 时及时返回。另外可以加一个简单的迭代加深,先搜浅层再搜深层,配合时间限制。
4.3 评估函数正负号搞反:AI 帮对手下棋
现象:AI 不仅不堵对手的活三,反而在自己的活三旁边下子,看起来像在帮对手。原因:评估函数里进攻分和防守分的符号搞反了,或者极小化极大搜索里层和层的颜色切换逻辑写错。解决:打印搜索树前几层的分值,确认当前层是最大化还是最小化。一个简单验证方法:让 AI 执黑对随机走子的白棋,如果 AI 能稳定赢,说明评估方向基本正确。
4.4 长连判定漏掉斜向:只扫了横竖
现象:黑棋在斜向形成六连,但程序没有判负。原因:长连判定只遍历了DIRS的前两个方向,漏掉了两个斜向。解决:确认DIRS四个方向都参与长连扫描。这个坑很隐蔽,因为横竖长连容易被测到,斜向容易被忽略。建议写一个专门测试斜向长连的局面。
4.5 临时落子后忘记撤销:棋盘状态污染
现象:AI 搜索几层后,棋盘上出现不该有的棋子,或者某些空位变成非空。原因:禁手判定或评估函数里临时落子后没有撤销,导致状态泄漏。解决:所有临时修改棋盘的操作,必须成对出现落子和撤销。可以用 RAII 封装一个TempMove类,构造时落子,析构时撤销,避免手动遗漏。
struct TempMove { Board& b; int r, c, old; TempMove(Board& board, int row, int col, int val) : b(board), r(row), c(col), old(board.at(row, col)) { b.at(r, c) = val; } ~TempMove() { b.at(r, c) = old; } };这个类构造时保存旧值并落子,析构时恢复。用它替代手动落子撤销,能大幅减少状态污染问题。注意拷贝构造要禁用,避免意外复制导致多次撤销。
5. 进阶技巧:用置换表和历史启发让 AI 快起来
5.1 置换表:避免重复搜索相同局面
搜索树里很多局面会重复出现,尤其是不同落子顺序到达同一盘面。置换表用哈希值缓存已搜索过的局面及其分值,下次遇到相同局面直接查表。五子棋的 Zobrist 哈希实现简单:给每个位置每种颜色分配一个随机数,落子时异或,撤销时再异或回来。
#include <random> #include <unordered_map> uint64_t zobrist[SIZE][SIZE][3]; std::unordered_map<uint64_t, int> transTable; void initZobrist() { std::mt19937_64 rng(12345); for (int r = 0; r < SIZE; ++r) for (int c = 0; c < SIZE; ++c) for (int k = 0; k < 3; ++k) zobrist[r][c][k] = rng(); } uint64_t hashBoard(const Board& b) { uint64_t h = 0; for (int r = 0; r < SIZE; ++r) for (int c = 0; c < SIZE; ++c) h ^= zobrist[r][c][b.at(r, c)]; return h; }initZobrist在程序启动时调用一次,hashBoard计算当前盘面哈希。实际使用时,落子后增量更新哈希比全量计算快得多:落子时异或旧状态和新状态。置换表里存深度和分值,查表时如果缓存深度大于等于当前剩余深度,直接返回。
5.2 历史启发:让好棋先被搜到
Alpha-Beta 剪枝的效率高度依赖候选点的顺序。如果先搜好棋,剪枝会更早发生。历史启发维护一个表,记录每个位置历史上导致剪枝的次数,搜索时按次数从高到低排序候选点。
int history[SIZE][SIZE] = {0}; void sortMoves(std::vector<std::pair<int,int>>& moves) { std::sort(moves.begin(), moves.end(), [](auto& a, auto& b) { return history[a.first][a.second] > history[b.first][b.second]; }); }每次某个点导致剪枝,就增加它的历史分值。注意历史分值会随时间累积,可以定期衰减,避免旧数据主导排序。这个技巧实现简单,但对搜索速度提升明显,尤其是深度 6 以上时。
5.3 一个验证 AI 是否真的懂禁手的小测试
写完禁手和 AI 后,怎么确认 AI 真的在规避禁手,而不是碰巧没走到?我一般会构造一个局面:黑棋如果走某个点会形成三三禁手,但那个点看起来能形成活三,对 AI 很有吸引力。如果 AI 正确避开,说明禁手过滤生效。再构造一个局面:黑棋走某点形成四四禁手,但能立刻成五,看 AI 是否因为禁手而放弃。这两个测试能覆盖大部分禁手联调问题。
另外,可以写一个自动对弈脚本,让 AI 执黑对随机白棋跑 100 局,统计禁手判负次数。如果禁手判负次数为 0,说明过滤逻辑基本可靠;如果出现禁手判负,打印局面复盘,定位是哪个判定环节漏了。
我自己的习惯是,每次改完禁手判定或评估函数,先跑这组测试再跑完整对弈。早期偷懒跳过测试,结果在一次评估函数调整后,AI 开始频繁走禁手,排查了半天才发现是活三判定里一个边界条件写反了。希望帮到你。
本文还有配套的精品资源,点击获取