简介:一份基于Unity引擎开发的五子棋人机对战小游戏项目资源,面向游戏开发初学者与对AI博弈算法感兴趣的开发者,帮助读者通过完整项目实践掌握Unity场景搭建、C#脚本控制游戏逻辑,以及Minimax、Alpha-Beta剪枝等经典搜索算法在人机对弈中的落地方法。压缩包整体约20.43MB,适合直接导入Unity工程学习与二次开发。目前已有527人学习下载。资源内容围绕实际项目展开,既包含对游戏规则、胜负判断等基础模块的实现思路,也重点剖析AI决策系统的设计:通过评估函数量化棋盘局面,结合剪枝优化搜索效率,让程序具备稳定的人机对战能力。开发者从中既能巩固面向对象编程与Unity组件通信,也能系统了解从游戏玩法到AI策略的完整开发链路。对于计划构建类似回合制策略游戏或想深入理解博弈树搜索的读者,是一份值得参考的实战资源。
1. 五子棋人机对战:从棋盘到会赢的 AI,难点不在规则而在权重
拿到「五子棋人机对战小游戏」这个标题,多数人第一反应是「AI 怎么写得聪明一点」。实际做下来,真正消耗时间的是另一件事:评估函数里的权重设计。规则只花一个下午就能写完,但 AI 是「贪吃型」还是「防守型」,活三和冲四该给多少分,这些参数直接决定电脑是高手还是智障。做过这类小项目的人都有体会:AI 老下出送杀棋,不是搜索代码写错了,而是评分表没调对。
这一篇把五子棋人机对战从棋盘建模、胜负判定、AI 决策到避坑完整过一遍,用一套能跑的 Python 命令行实现把每个环节讲透。适合三类人:想练手博弈算法的开发者、需要交课程设计的学生(A 同学已经用这套框架应付过某高校的实训项目)、以及想搞懂「AI 为什么会这样下棋」的爱好者。全文不依赖任何第三方库,照着敲就能跑。
2. 先把棋盘和规则写对:建模比 AI 更值得花时间
2.1 棋盘的数据结构:二维数组够用,但索引别用错
五子棋棋盘主流是 15×15,也有 19×19(棋力更强,但小游戏用 15 路更合适)。数据结构上最简单可靠的方案就是二维数组,行列从 0 开始编号,空位存 0,黑子存 1,白子存 2。
class GomokuBoard: def __init__(self, size=15): self.size = size self.board = [[0 for _ in range(size)] for _ in range(size)] self.move_count = 0 def in_bounds(self, x, y): return 0 <= x < self.size and 0 <= y < self.size def place(self, x, y, player): if not self.in_bounds(x, y): return False if self.board[x][y] != 0: return False self.board[x][y] = player self.move_count += 1 return True很多人第一次写会用board[x][y]代表「第 x 行第 y 列」,但 GUI 里鼠标点击返回的往往是 (col, row),也就是列在前。如果前端和后端坐标约定不一致,就会出现「点上面棋子落到左边」的灵异现象。我习惯在这层统一用(x, y)表示行列,前端传入时先做一层转换,不要在棋盘类内部塞两种语义。
参数说明:size默认 15,如果后续要换 19 路棋盘,只改这一处就行。place返回布尔值表示是否落子成功,这比直接改数组更安全,落子前的边界检查和占用检查都收口在一个方法里。
2.2 胜负判定:四方向扫描是最稳的方案
胜负判定的常见写法有两种:落子后只检查以该点为中心的四个方向(横、竖、两个斜),或者每次全盘扫描。前者效率高很多,也符合「只有刚落下那颗子可能形成五连」的逻辑。
def check_win(self, x, y, player): directions = [(0, 1), (1, 0), (1, 1), (1, -1)] for dx, dy in directions: count = 1 for sign in (1, -1): nx, ny = x + sign * dx, y + sign * dy while self.in_bounds(nx, ny) and self.board[nx][ny] == player: count += 1 nx += sign * dx ny += sign * dy if count >= 5: return True return False这段代码的逻辑:从落子点出发,沿每个方向的正反两个方向延伸计数,累计同色棋子数。只要任意方向达到 5 就判定胜利。(1, -1)是副对角线方向,很多人会漏掉这个方向导致斜着五连判不出来。
注意count >= 5而不是count == 5,因为棋盘上可能出现六连甚至更长(如果没启用禁手规则)。这种写法天然支持长连判定,后续要加禁手规则也方便。
满盘判定同样不能漏。五子棋不是必须下满才能结束,但程序必须有「平局」出口。我一般在place里维护move_count,每次落子后检查是否等于size * size。
def is_draw(self): return self.move_count >= self.size * self.size一个很容易翻车的点:许多初学者把胜负判定写在 AI 搜索函数里,每次递归都扫一遍全盘。这在小棋盘上勉强能跑,但在 15 路棋盘叠加搜索深度后就变成性能灾难,正确做法是只在落子后判定一次。
3. 让 AI 会下棋:评分表让电脑「看懂」局面
3.1 为什么要评分而不是模拟人脑
想让电脑下棋,最直接的想法是「把高手经验写成规则」。但规则一多就互相冲突:什么时候该进攻、什么时候该防守,局面稍变规则就失灵。更可靠的做法是给每个空位打分:评估「我如果落在这里」的进攻价值和「对手如果落在这里」的防守价值,然后取综合分最高的位置。
棋盘上每个位置都有一个分数,搜索算法只是在这个分数基础上做更深层的推演。评分表设计的目标是:让 AI 在当前局面下选出的落点和人脑直觉一致。比如对手已经形成冲四,AI 必须马上堵;自己有机会形成活三,AI 应该优先扩展而不是去边角乱下。
3.2 棋型识别与评分表设计:活四、冲四、活三的权重怎么定
评分算法一般对每个空位做四方向扫描:假设该位置放上我方棋子,沿四个方向统计连续同色棋子数量,并检查两端是否被堵。
def evaluate_point(self, x, y, player): if self.board[x][y] != 0: return -1 # 非法位置 total_score = 0 directions = [(0, 1), (1, 0), (1, 1), (1, -1)] # 临时放置棋子 self.board[x][y] = player for dx, dy in directions: count = 1 open_ends = 0 for sign in (1, -1): nx, ny = x + sign * dx, y + sign * dy while self.in_bounds(nx, ny) and self.board[nx][ny] == player: count += 1 nx += sign * dx ny += sign * dy if self.in_bounds(nx, ny) and self.board[nx][ny] == 0: open_ends += 1 total_score += self.shape_score(count, open_ends) self.board[x][y] = 0 # 恢复棋盘 return total_scoreevaluate_point的核心是临时把棋子放上去、算完分再拿掉。这段代码用到了「模拟落子」技巧,后续搜索算法会反复用这一招,所以把它写成独立函数很重要。
shape_score是权重表,这是整个五子棋 AI 最玄学的部分。我调过的参数如下:
| 棋型 | 连子数 | 开放端 | 分数 |
|---|---|---|---|
| 五连 | 5 | 任意 | 1000000 |
| 活四 | 4 | 2 | 500000 |
| 冲四 | 4 | 1 | 100000 |
| 活三 | 3 | 2 | 20000 |
| 眠三 | 3 | 1 | 5000 |
| 活二 | 2 | 2 | 2000 |
| 眠二 | 2 | 1 | 500 |
def shape_score(self, count, open_ends): if count >= 5: return 1000000 if count == 4: return 500000 if open_ends == 2 else 100000 if count == 3: return 20000 if open_ends == 2 else 5000 if count == 2: return 2000 if open_ends == 2 else 500 return 0为什么活四给 50 万而冲四只给 10 万?活四意味着两端都开放,下一步无论对手堵哪边都能五连,这是必杀棋,要给极高权重;冲四虽然也有威胁,但对手必须堵,威胁可控。活三给 2 万,比冲四低一档,因为它只是「即将形成活四」的前兆。这个层级关系不能乱,一旦活三分数超过冲四,AI 就会在对手冲四时不防守、跑去发展自己的活三,结果被人一步杀。
我的经验是:权重尽量取整数倍关系,不要出现 20000 和 23000 这种接近值,否则搜索深度一深分数叠加后容易出诡异行为。
3.3 综合攻防:AI 落子不只看进攻分
如果 AI 只看「我落在这里能得多少分」,它会变成纯进攻型选手,对对手的威胁视而不见。实战中需要同时算对方的得分,再按一定比例综合。
def get_best_move(self, board, ai_player): best_score = -1 best_move = None human_player = 3 - ai_player for x in range(board.size): for y in range(board.size): if board.board[x][y] != 0: continue attack_score = board.evaluate_point(x, y, ai_player) defense_score = board.evaluate_point(x, y, human_player) total = attack_score * 1.0 + defense_score * 0.9 if total > best_score: best_score = total best_move = (x, y) return best_move3 - ai_player是切换玩家 id 的小技巧:玩家 1 和 2,3 减 1 得 2,3 减 2 得 1。防守分乘以 0.9 是让 AI 稍微偏进攻,但又不至于忽视防守。这个比例很关键,如果调成 0.5,AI 会变得过于保守,经常在局面大优时跑去堵一个没什么威胁的二连。
这段代码就是「贪心版 AI」,只评估眼前一步。它能下出基本合理的棋,但很容易被「两步以上的杀棋」击穿。要提升棋力,需要引入搜索树。
4. 更聪明的 AI:极大极小搜索与 Alpha-Beta 剪枝
4.1 从贪心到搜索:为什么单点评分不够
贪心 AI 的问题是「近视」:它只看到当前这一步的分数,看不到对手下一步的反击。比如我冲四,从单点看进攻分很高,但对手堵完之后我没有任何后续手段,这步棋其实是废棋。搜索算法的目标是把「我下一步、对手下一步、我再下一步」的完整过程模拟出来,选一条最终对自己最有利的路径。
极大极小搜索的思路:我方回合选分数最大的走法(极大节点),对手回合假设他会选让我分数最小的走法(极小节点)。交替递归,最后一层用评估函数打分往回传。
4.2 在有限深度内搜出好棋:Alpha-Beta 的实现思路
完整极大极小搜索在 15 路棋盘上不可能穷举,每一步有 200 多个合法落点,搜索深度 4 就已经是天文数字。实战必须做两件事:候选点裁剪和 Alpha-Beta 剪枝。候选点裁剪的意思是只对分数最高的前 N 个空位做搜索,把分支数从两百多砍到十个左右。
def get_candidate_moves(board, ai_player, top_n=10): human_player = 3 - ai_player scored = [] for x in range(board.size): for y in range(board.size): if board.board[x][y] != 0: continue attack = board.evaluate_point(x, y, ai_player) defense = board.evaluate_point(x, y, human_player) scored.append((attack + defense, x, y)) scored.sort(reverse=True, key=lambda t: t[0]) return [(x, y) for _, x, y in scored[:top_n]]Alpha-Beta 剪枝的代码在概念上比实现难理解。核心就一句:极大节点一旦发现某个分支的分数已经低于对手之前给出的上界,就不继续探索了,因为对手永远不会选择让你走上这条路。
def alphabeta(board, depth, alpha, beta, is_maximizing, ai_player): if depth == 0: return evaluate_board(board, ai_player) candidates = get_candidate_moves(board, ai_player, top_n=8) if is_maximizing: best = -float('inf') for x, y in candidates: board.board[x][y] = ai_player score = alphabeta(board, depth - 1, alpha, beta, False, ai_player) board.board[x][y] = 0 best = max(best, score) alpha = max(alpha, score) if beta <= alpha: break return best else: best = float('inf') human_player = 3 - ai_player for x, y in candidates: board.board[x][y] = human_player score = alphabeta(board, depth - 1, alpha, beta, True, ai_player) board.board[x][y] = 0 best = min(best, score) beta = min(beta, score) if beta <= alpha: break return best这里需要注意两点。第一,落子模拟后一定要恢复棋盘,board.board[x][y] = 0这行漏掉会让整个搜索逻辑崩溃,而且是随机崩溃,排查起来极其痛苦。第二,评估函数evaluate_board是对当前整个棋盘打分,而不是对某个空位打分。常见做法是遍历棋盘上所有棋子,累加每个棋子的棋型分数,或者用「当前局面 AI 总得分减去对手总得分」这种差值评估。
get_candidate_moves的top_n和alphabeta的depth是这套 AI 的核心调参位。深度 2 配候选 8,AI 棋力大约等于刚学会规则的新手;深度 4 配候选 10,已经有明显战力。再往上就会面临性能瓶颈,后面避坑章节细说。
4.3 难度分级:改动深度和候选点就能做出三档 AI
小游戏要有可玩性,AI 不能永远下最强。难度分级不需要重写算法,改两个参数就行:top_n和depth,再配合一点随机性。
| 难度 | 搜索深度 | 候选点数 | 随机扰动 | 适用场景 |
|---|---|---|---|---|
| 简单 | 1 | 5 | 30% 概率随机选前 5 之外的点 | 新手熟悉规则 |
| 中等 | 2 | 8 | 无 | 休闲玩家 |
| 困难 | 4 | 10 | 无 | 想认真下棋 |
随机扰动实现起来很简单:算好候选列表后,有一定概率不选第一名而选第二名或第三名。这样 AI 的棋风不会一成不变,玩家也不容易摸透套路。注意扰动只在简单难度启用,中等以上必须稳定输出最强手,否则玩家会骂 AI 作弊或变笨。
这里有个血泪经验:困难模式搜索时间可能到两三秒,界面会卡。常见做法是把搜索放到后台线程,前端先显示「思考中…」的提示。命令行版本没这个困扰,但如果你改成 Web 版或桌面版,必须处理异步。
5. 五子棋人机对战避坑指南:权重失衡、先手优势和算不完的搜索
5.1 活三冲四权重设反,AI 会送杀棋
现象:AI 明明看到对手已经冲四,却跑去下自己的活三,下完被对手直接五连带走。原因:权重表里活三(20000)和冲四(100000)差距不够,或者更严重——活三给高了。当搜索深度不足时,AI 评估活三的长期收益(20000 × 后续叠加)竟然超过了冲四的单步威胁。解决:冲四权重必须至少是活三的五倍以上,最好在代码里加一个提示——如果冲四的分数填得比活三还低,那 AI 必然看不懂「必须堵」的棋。用我上面的参数表不会出这个问题,魔改权重时要特别小心。
5.2 先手被压制是因为没考虑「防守分」
现象:玩家先手,AI 后手,AI 经常缩在角落里下一些无关痛痒的棋,任由玩家铺开局面。原因:AI 只算了自己的进攻分,没算玩家的进攻分。当玩家形成三连时,AI 的每个防守点得分都不高,它觉得「反正堵了也没分」就不堵了。解决:确保综合分数包含防守项,即evaluate_point(x, y, human_player)的分数参与排序。防守分系数 0.9 是我调得比较顺的值,如果 AI 太浪就加到 1.0,如果太怂就减到 0.7。
5.3 棋盘越下越卡:候选点裁剪和搜索超时
现象:前期落子飞快,下到中盘每步要等好几秒,后期直接像死机。原因:搜索深度固定,但棋盘上合法空位越来越少时get_candidate_moves的基数变小了——这不是变慢的原因;真正慢的往往是 AI 把自己所有棋子都当作「潜在攻击点」来评估,评估函数的遍历量每层都很大。另一个常见原因是top_n设得太大,深度 4 配 top_n 15 就是 15 的 4 次方约 5 万次评估,每次评估还要扫描四方向。解决:把top_n从 10 降到 6,深度从 4 降到 3,速度能提升一个数量级;或者加超时保护,搜索超过 2 秒就返回当前最优候选。
5.4 平局和满盘的判定漏掉会导致死棋
现象:棋盘下满后程序没反应,既不说胜利也不说平局,游戏卡死在最后一步。原因:is_draw检查放在了一个永远不会执行到的分支里,或者根本没有写。解决:每次落子后先查胜负、再查平局,顺序不能反。还有一个容易漏的情况:move_count忘了在place里自增,导致is_draw永远返回 False。这类逻辑 bug 单测很难发现,建议跑一个「自动对弈到满盘」的测试脚本,强制触发平局路径。
5.5 主循环玩家切换的隐坑
现象:玩家和 AI 轮流下棋,但有时候 AI 连续下两步,或者玩家可以连续落子。原因:主循环里切换当前玩家current_player = 3 - current_player放在 AI 搜索之前,AI 还没落子玩家标识已经切走了,导致 AI 落子后又轮到玩家。解决:切换必须放在「该玩家完成落子之后」。更稳妥的做法是主循环结构写成while True: if current_player == human: 等待输入; else: AI 计算; 落子; 检测胜负; current_player = 3 - current_player。
6. 进阶:悔棋、禁手规则与自动对弈验证
6.1 用悔棋和重新开局兜底体验
小游戏不能输一把就关掉重开。悔棋实现思路:维护一个落子历史栈move_history,每步落子后把(x, y, player)压栈。悔棋时弹出最近两步(玩家一手 AI 一手),把棋盘对应位置清零,move_count减 2。重新开局只需要重新初始化棋盘对象。这个功能技术上很便宜,但对体验的提升非常明显。
6.2 禁手规则:比想象中多一层判断
正式五子棋有禁手规则:黑方(先手方)禁止形成双三、双四和长连。但小游戏大多不启用禁手,原因很简单:AI 会利用禁手规则逼玩家失误,普通玩家根本不知道禁手是什么,体验很差。如果要做禁手,实现上是在胜负判定前多加一组棋型识别,检查黑方落子后是否同时形成两个活三或两个冲四。我的建议是做成开关,默认关闭,高级模式再开启。
6.3 让 AI 自己和自己下棋:验证棋力的最好方法
调完参数最想知道的是「AI 变强了没有」。与其找朋友试玩,不如写一个自动对弈脚本,让两个 AI 左右互搏,跑一百局统计胜率。如果目的是验证「某个权重调整是否有效」,固定先手方不变,对比调整前后胜率变化即可。我用这个方法发现了一件反直觉的事:把活三的分数从 20000 调到 30000 后,AI 胜率反而下降了,因为它更热衷于造活三,忽略了对手冲四的威胁。
def simulate(ai1, ai2, rounds=100): stats = {"ai1_win": 0, "ai2_win": 0, "draw": 0} board = GomokuBoard() for _ in range(rounds): board.__init__() current = 1 while True: if board.is_draw(): stats["draw"] += 1 break move = ai1.get_best_move(board, current) board.place(*move, current) if board.check_win(*move, current): stats["ai1_win" if current == 1 else "ai2_win"] += 1 break current = 3 - current return stats这个测试脚本还能做边界测试:把棋盘下满强制触发平局分支,或者故意在 AI 搜索前传入一个非空棋盘检查它能不能正确处理已有局面。自动对弈跑上一百局,很多只在特定局面下出现的逻辑错误才会暴露出来。
做五子棋人机对战这几个月下来,我最深的体会是:别迷信搜索深度,先保证评估函数是「对」的,再考虑让 AI 是「聪明」的。权重表调不好,深度再高也只是让 AI 更快地下出烂棋。现在每接到这类小项目,我的习惯是先写出能稳定运行的贪心 AI 打底,确认规则和界面没有问题,再逐步替换成带剪枝的搜索版本——一步一步来,每一步都有可验证的中间产物,翻车概率会低很多。希望帮到你。
本文还有配套的精品资源,点击获取