简介:一份基于Unity引擎实现的五子棋人机对战小游戏项目资源,面向Unity游戏开发、C#编程以及AI算法感兴趣的初中级学习者,适合作为课程设计或技术入门参考。资源以zip压缩包形式提供,整体大小约20.43MB,便于快速下载与本地运行。项目围绕一个完整可玩的小型游戏展开,既包含Unity场景与交互界面的搭建思路,也涉及C#脚本对游戏规则、胜负判断和落子响应的实现;核心亮点是AI模块对博弈树搜索的应用,通过评估函数对棋盘局势打分,并利用Minimax算法与Alpha-Beta剪枝来优化决策路径,减少无效搜索。跟随该项目实践,可以较直观地理解状态空间搜索在游戏AI中的落地方式,同时掌握将算法转化为实际游戏功能的方法。当前已有522人学习,对希望结合游戏体验来学习AI原理的开发者来说,是一个轻量而完整的示例。 如果你让我给刚接触编程的朋友推荐一个练手项目,我的答案大概率是五子棋人机对战。它看上去不过是一个15×15的棋盘,但要把“电脑会下棋”这件事真正做出来,你得同时啃掉数据结构、算法设计、状态管理、事件驱动这几块硬骨头。我从最早用控制台打印棋盘,到后来用Canvas做网页版,再给AI逐步加上评估、搜索和剪枝,前后迭代了好几版。这篇文章就按我实际开发的顺序,把从零到一的完整过程和踩过的坑一起写出来。想找个完整项目练手、又打算搞懂AI决策原理的朋友,应该能从里面拿走不少东西。
1. 动手之前:拆解一次人机对弈到底要做什么
很多人拿到这个需求,第一反应是打开编辑器画棋盘,画完就开始写点击落子,写着写着发现逻辑乱成一团。我自己的习惯正相反,先不动代码,把“一次完整对弈”从头到尾在脑子里过一遍:棋盘初始化,玩家落子,判断是否五连,AI思考,AI落子,再判断,切换回合……把这个流程一拆,项目的边界就清楚了。
1.1 四个必须分开的模块
五子棋人机对战看着简单,但至少包含四块独立的东西:
- 棋盘状态:记录15×15个交叉点上的棋子状态,是整个对弈的共享数据模型。
- 落子规则:坐标换算、合法性校验、落子后的胜负判定,属于业务逻辑层。
- AI决策:输入当前局面和执棋颜色,输出落子坐标,是整场博弈的大脑。
- 界面表现:画棋盘、画棋子、处理鼠标事件、展示胜负结果。
这四块可以分别独立开发和测试。我的顺序是先把棋盘状态和落子规则写完,让玩家能手动交替下棋,再用最简单的随机落子把AI占位,跑通整局;最后才把真正的AI算法换进去。分模块还有一个隐性好处:调试AI时不需要每次从零开局,直接在代码里构造一个指定的棋盘局面丢给它,就能验证某个具体情形下的决策是否正确。
1.2 技术选型:为什么我建议先用Web前端
这篇文章里的示例用HTML + JavaScript + Canvas实现。选它不是因为性能多强,而是因为一个文件就能在浏览器里双击运行,零环境依赖,对新手最友好。JavaScript的数组和对象操作非常直白,写AI算法时不用操心内存管理,调试也方便。如果你更想练Python,整体思路完全一致,把Canvas换成Pygame,逻辑层代码基本可以平移。至于要不要上Vue/React这类框架,我的看法是:这个项目规模还用不上框架,硬上反而多一层心智负担,等做到联机对战、房间管理再引入也不迟。
1.3 用状态机管理回合流转
整局对弈可以看成一个非常简单的状态机:轮到玩家、AI思考中、轮到AI、回到玩家,每一步落完都要检查游戏是否结束。我代码里用一个turn变量表示当前执棋方,用isThinking标志锁住玩家输入,一旦胜负判定通过,就把状态切到GAME_OVER。很多初学者项目出现“玩家连下两子”“AI还没想好,玩家又点了一下”这类bug,本质都是回合状态没有管理清楚。提前把这个状态流转想明白,后面写事件处理会顺畅很多。
2. 棋盘数据与胜负判定:地基没打好,后面全是补丁
棋盘的表示方式看似简单,但它是整个项目的地基。AI评估函数要在棋盘上做大量方向扫描,如果这个数据结构选得不顺手,后面写起来会很难受。
2.1 棋盘用二维数组还是其他结构
棋盘数据我推荐最直接的二维数组:
const board = Array.from({ length: 15 }, () => new Array(15).fill(0));用0表示空、1表示黑子、2表示白子。有人会用一维数组甚至位运算来压缩存储,性能确实更好,但在这个项目里完全没必要。二维数组board[row][col]的索引方式最贴近人脑,后续写AI评估函数时要频繁沿着四个方向扫描相邻棋子,这种可读性带来的收益远大于那一点点性能提升。
2.2 坐标换算:像素坐标与格子坐标的边界
Canvas绘制用的是像素坐标,业务逻辑关心的是格子坐标。棋盘左上角留出边距margin,格子间距为cellSize,那么鼠标点击位置可以这样算:
const col = Math.round((e.offsetX - margin) / cellSize); const row = Math.round((e.offsetY - margin) / cellSize);计算完再判断row和col是否落在0到14范围内。这里要特别提醒:一定要先四舍五入再判断越界,否则点在棋盘边缘很容易算出负数坐标,一个看着很正常的落子动作就变成数组越界了。计算格子索引后,绘制时用margin + col * cellSize来定位交叉点,棋子和格线才能严格对齐。
2.3 胜利判定:以小见大,只扫四根线
最简单也最不容易出bug的胜利判定,是每次落子后只从当前落子点向四个方向扫描:水平、垂直、主对角线、副对角线。每个方向向两端延伸,统计同色连续棋子数,只要有一个方向连续数大于等于5就结束。
function checkWin(row, col, player) { const dirs = [[1, 0], [0, 1], [1, 1], [1, -1]]; for (const [dx, dy] of dirs) { let count = 1; for (let step = 1; step < 5; step++) { const nr = row + dx * step, nc = col + dy * step; if (board[nr]?.[nc] !== player) break; count++; } for (let step = 1; step < 5; step++) { const nr = row - dx * step, nc = col - dy * step; if (board[nr]?.[nc] !== player) break; count++; } if (count >= 5) return true; } return false; }为什么只检查当前落子点?因为新增的一颗子,只可能让包含它的新棋形获胜。如果此前已经五连,对局早就结束了,根本轮不到这一步。这里用可选链board[nr]?.[nc]来规避数组越界判断,如果你用Python,写成if 0 <= nr < 15 and 0 <= nc < 15也是一样的效果。四方向扫描的复杂度是常数级,对每步棋来说几乎不耗时间。
2.4 悔棋与重开:历史栈带来的状态回退
悔棋在逻辑层就是弹栈。维护一个moves数组,每次落子都push进{row, col, player}。悔棋时从栈里弹出一步,把board对应位置重置为0,再重绘。这个功能看着容易,其实藏着一个新手很容易踩的坑:玩家悔棋,悔的往往是自己刚走的那一手的正对面,也就是AI刚下的那一手。如果只弹一步,棋盘状态退回到AI落子前,但当前回合又是玩家回合,AI没有重新思考的入口,整个局面就僵住了。
我处理的方案是,玩家回合悔棋时连续弹出两步,把玩家和AI的各一步一起退掉,然后重新轮到玩家。另外,重开一局时要把board、moves、turn、winner全部重置干净。我早期只重置了board,结果点“再来一局”之后,悔棋按钮还能弹出上一局的历史记录,整个状态全乱了——这种脏状态问题排查起来很费劲,最好在一开始就做好完整reset函数。
3. AI决策核心:评分、搜索和剪枝的一次完整配合
AI是五子棋人机对战项目的灵魂。很多教程讲到AI就直接上Minimax,但我觉得先理解评分逻辑更重要,因为搜索算法是在评分基础上做推演的,基础不打牢,后面全是空中楼阁。
3.1 AI到底在思考什么
AI下棋的本质,是对每一个可选落子点打分,然后选分数最高的那个。但“分数高”必须用棋手的方式定义。我们要扫描一个点四方向形成的棋形:连续几个子、两端是否被封死,根据棋形给出不同档位的分数。我用的是一套常见的参考分数:
| 棋型 | 分数 | 说明 |
|---|---|---|
| 五连 | 100000 | 直接获胜 |
| 活四 | 50000 | 两端都开放,下一手必胜 |
| 冲四 | 10000 | 只有一端开放,对手必须堵 |
| 活三 | 5000 | 再下一手可成四 |
| 眠三 | 1000 | 威胁有限但需要警惕 |
| 活二 | 500 | 开局布局的基础 |
| 眠二 | 100 | 影响力较小 |
这套分数的绝对值不是关键,级差才是。五连必须大得离谱,活四和冲四的差距要严格拉开,这样AI才明白:该赢的时候直接赢,该冲四的时候别去贪一个活三。分数设置不合理的话,AI经常会出现“优势局面不走制胜手”的诡异行为,这种问题查起来很让人头疼。
3.2 判定棋型:把一条线切成片段
要算一个点在某个方向上的棋型分数,最直接的做法是以这个点为中心,向两端各延伸若干格,统计连续的同色棋子数,以及两端的状态。我把这部分抽成evaluateDirection函数,返回四个方向的分数之和。
function evaluateDirection(row, col, player, dx, dy) { let count = 1; let openEnds = 0; // 沿正方向延伸 let nr = row + dx, nc = col + dy; while (inBoard(nr, nc) && board[nr][nc] === player) { count++; nr += dx; nc += dy; } if (inBoard(nr, nc) && board[nr][nc] === 0) openEnds++; // 沿反方向延伸 nr = row - dx; nc = col - dy; while (inBoard(nr, nc) && board[nr][nc] === player) { count++; nr -= dx; nc -= dy; } if (inBoard(nr, nc) && board[nr][nc] === 0) openEnds++; return shapeScore(count, openEnds); }这里最关键的是区分“两端都开放”和“一端被封死”。同样是连续三个子,活三和眠三的威胁完全不是一个量级。扫描时还要小心己方棋子被对手隔断的情况,例如“空、黑、黑、白、黑”,两个黑的片段绝不能合并算一个活二。评估函数是整个AI的基石,它准不准直接决定AI的棋力上限。我调试时经常把某个候选点的每个方向分数打印出来,一旦发现AI走了明显不合理的棋,多半就是某个方向的棋型判断漏了边界情况。
3.3 进攻分与防守分:会进攻也得会堵
只计算“我落子后的价值”会出大问题:AI只顾发展自己的活三,完全无视对手已经冲四的事实,最后被一手反杀。我第一次跑到这个bug时还挺惊讶的,因为单看代码逻辑感觉没什么问题。解决方案是每个候选点同时算两遍分:进攻分(我下在这里的价值)和防守分(如果让对手下在这里的价值)。
最终得分可以用进攻分 * 1.1 + 防守分来算。为什么要乘1.1?只是让AI在势均力敌时略微激进一点,优先展开自己的攻势。一旦对手出现高威胁棋型,防守分本身会大到压倒一切,AI自然知道该去堵。系数用1.1还是1.2可以自己调,我的经验是系数太高会让AI过于激进,明明该防守的时候还在拼命进攻,反而容易崩盘。
3.4 从贪心到极小化极大:一步与多步的差距
贪心选点实现简单,但短视。经典的双三局面里,AI只看一步时永远只能堵住一个活三,对手下一手在另一侧形成连接就赢了。要处理这种组合杀,需要把后续回合也纳入考虑。
最经典的做法是极小化极大搜索:AI作为Max方,对手作为Min方,双方交替模拟落子若干步,每层都选择能让自己评分最大、让对手评分最小的分支。这个思路跟下棋的直觉完全一致——我不能只考虑自己这一步爽不爽,还要想想对手可能的回应。配上Alpha-Beta剪枝,可以把大量没必要的分支直接剪掉,搜索效率会高很多。
3.5 搜索优化的两个杀手锏
五子棋的搜索宽度其实不小,15×15共225个空位,如果全量枚举,再强的剪枝也撑不住。我实际应用了两个非常有效的优化。
第一是候选点裁剪:只搜索已有棋子周围两格半径内的空位。远离棋子的位置几乎不可能是合理落点,候选点能从200多个降到20个左右,搜索规模直接小一个量级。第二是启发式排序:递归之前先按评估分数把候选点排个序,让高分走法先被搜索。Alpha-Beta剪枝的效率极度依赖节点访问顺序,如果先搜的是差分支,剪枝规则根本触发不了;排序之后剪枝率会大幅提升。实测在JavaScript里,深度4、候选点20个左右的情况下,单步搜索稳定在几百毫秒,配合异步任务处理完全不会卡住界面。
4. 界面交互与视觉反馈:让游戏至少拿得出手
算法再强,界面一塌糊涂也没人愿意玩。我在这个项目里最大的体会是,交互细节带来的体验提升,往往比多写一百行算法更直观。
4.1 画棋盘:细节决定观感
棋盘我是按600×600像素画的,15×15格、格子间距40像素、周围边距20像素。画布背景用接近木纹的浅黄色,格线用深棕色,整体颜色偏暖,看着比较舒服。棋子用fillStyle加arc圆来画,但别用纯黑纯白:黑子用深灰径向渐变,白子加一圈浅灰描边,否则画面会显得很死板。另外我强烈建议落子前用mousemove画一个半透明预览子,让玩家清楚看到当前悬停在哪个交叉点。这个小细节对体验的提升非常明显,玩家不需要自己数格子。
4.2 鼠标事件与回合锁
点击事件主要做三件事:换算格子坐标、校验空位、执行落子。但AI思考期间必须锁住输入,否则玩家快速点击会造成棋盘状态错乱。我用isThinking标志位,true时click事件直接return。玩家落子后立即把isThinking置为true,AI落子完成后再置回false。棋盘上方加一条状态栏,显示当前是谁的回合,玩家就不需要猜测自己该不该点。状态栏文案也很简单,轮到玩家显示“你的回合”,AI思考时显示“电脑思考中……”。
4.3 胜负呈现:别让体验停在alert
判断到五连之后,我会用一层半透明遮罩盖住棋盘,中间显示“黑子胜”或“白子胜”,附一个“再来一局”按钮。很多入门教程直接用alert弹窗,体验相当差,而且alert会阻塞JavaScript主线程,让整个页面像崩溃了一样。弹结果前用setTimeout给落子动画留一点播放时间,否则玩家可能根本看不清最后一手落在哪里,结果就糊脸上了。这些看起来是小事,但一个游戏“能不能拿得出手”,很大程度上就差在这些细节上。
5. 实测中的坑与调优记录
任何项目写到能跑,只是第一步;把体验和棋力调到位,才是真正花时间的地方。这里记几个我在实测阶段真实踩过、也真实解决掉的坑。
5.1 深度上去后AI变慢,原因不是计算量大
第一次把搜索深度从2提到4,浏览器明显卡顿。我一开始以为是候选点太多,但降到10个候选点之后还是慢。实际排查发现,剪枝几乎没生效——因为我没对候选点做任何排序。Alpha-Beta剪枝非常依赖节点访问顺序,如果先搜到一堆差分支,剪枝规则根本触发不了,搜索树就退化成全量枚举。加上启发式排序之后,同样的深度下耗时反而下降。这个坑让我意识到,算法优化的顺序往往不是加算力,而是先把数据的访问顺序处理好。
5.2 评估函数的局部视野漏掉了组合杀
单点棋型评价天然漏掉一种情况:双威胁。比如某个点落子后能同时形成两个活三,这种棋形在单点评分里可能只被算成一个活三的分数,但实战里它就是必胜的组合杀。我给AI加了一个后处理:对得分最高的前N个候选点,额外检查落子后是否形成双三或双四,是的话直接加一个加权分。这个改动成本很低,棋力提升却很显著。如果你想让AI更强,还可以在这个基础上再考虑跳活三、跳冲四等特殊棋型,方向是一样的。
5.3 用自对弈来验证AI强度
写完AI怎么知道它到底行不行?我的土办法是让AI自己跟自己下,把落子顺序和关键点的评估分打印出来复盘。黑棋和白棋都要测:执黑时进攻顺畅,说明进攻端没问题;执白时如果总是被压着打,说明防守端评估或搜索深度还有欠缺。自对弈还能用来微调参数,我把进攻系数从1.0调到1.1再调到1.2,对比AI在同一组局面下的表现,最后选了一个比较平衡的值。这个办法虽然土,但没有它,调参基本靠蒙。
5.4 特别提醒:不要在第一版就追求完美
回头看这个项目,第一版只要能完成玩家落子、AI随机或贪心落子、五连判定,就已经是一个完整可玩的游戏了。AI强度可以无限优化,但项目从0到1、能跑起来,才是最关键的里程碑。很多朋友写这种小游戏容易陷入“永远在重构”的循环,我建议先把最笨AI的整条链路打通,再一步步替换成复杂算法。每轮迭代都保留一个可运行的版本,你的开发体验会好很多,也更容易坚持下去。
五子棋人机对战这个项目,我从控制台版一路做到网页版,最大收获不是哪个算法记得多熟,而是学会把一个模糊的“做一个游戏”的需求,拆成棋盘、逻辑、AI、交互四个清晰模块。特别是AI部分,让我真正理解了评分、搜索和剪枝在实践里是怎么协作的——很多算法书上轻描淡写的概念,自己从零写一遍才会撞上各种各样的边界情况。如果你想拿这个项目练手,照着我说的顺序来:第一版打通流程,第二版换成评分AI,第三版加上搜索和剪枝。到第三版跑通的时候,你对整个项目、对算法落地的理解都会有一个很实在的跃迁。
本文还有配套的精品资源,点击获取