做Unity练手项目,很多人第一反应是跟着官方教程做Roll a Ball或者Survival Shooter。但我自己第一个认真做完的项目,反而是这个看起来“太简单了”的贪吃蛇。原因很简单:动起手来你才会发现,贪吃蛇把游戏开发里最核心的一批问题全涉及了——输入与方向控制、移动计时、网格坐标与视觉坐标的换算、碰撞检测、动态对象生成、UI状态切换,甚至还能一路延伸到对象池和性能优化。这篇笔记不是从零教你怎么拖场景、摆预制体,而是记录我实际写这个项目时踩过的坑、反复权衡过的方案,以及最后留在工程里的做法。如果你正在用Unity做贪吃蛇,或者想通过一个小项目把Unity的基础机制串起来,这篇应该能帮你少走不少弯路。
1. 贪吃蛇这个“小学生项目”,为什么值得认真做一遍
1.1 一个小小的项目,却能覆盖Unity的核心循环
很多初学者会低估贪吃蛇的覆盖面。表面上它是一个几行逻辑就能讲清楚的小游戏,但真正用Unity实现时,你面对的是游戏循环里的每一个环节:场景初始化、玩家输入、状态更新、碰撞反馈、UI刷新、游戏结束与重开。
举几个具体的例子。贪吃蛇需要蛇身跟着蛇头移动,这涉及多个对象之间的坐标同步;吃食物要生成新的身体节点,这涉及Prefab实例化与资源管理;撞墙或撞到自己要停止游戏并弹结束界面,这涉及状态机切换和事件通知。哪怕只是把分数刷新到UI上,你也会接触到TextMeshPro和MonoBehaviour生命周期之间的关系。
所以贪吃蛇是一个很好的“最小闭环”项目。它不会大到让你失去耐心,又足够让你把Unity日常开发生涯里常用的模块全过一遍。做完之后你会发现,之后做任何2D小游戏,很多代码都可以直接复用或者稍微改改就能用。
1.2 为什么照着教程做,还是会出现各种“灵异现象”
网上关于Unity贪吃蛇的教程一大堆,但很多人照着敲完代码,运行起来却会发现一堆问题:蛇身转弯时会漂移,快速连按方向键会反向穿过自己,有时候已经撞墙了却没有触发游戏结束。这些问题大多不是因为教程代码写错了,而是教程只给了结果,没讲清楚“为什么这么写”。
最典型的就是蛇身跟随。很多教程会告诉你用一个List<Transform>存身体节点,移动时让后一个节点跟随前一个节点的位置。这个思路本身没错,但如果你的移动间隔、转向逻辑、生长逻辑和碰撞检测不在同一个节奏上,跑起来就会出各种怪现象。所以我在做这个项目时,特意把每个模块单独拆开想清楚,再合到一起。这也是这篇笔记的出发点:技术点本身不复杂,复杂的是它们之间的衔接和边界条件。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 蛇身跟随方案选型:历史点队列和链表节点,哪个更顺手
蛇身跟随是整个贪吃蛇的核心机制,也是网上讨论最多的问题。我实际试过两种主流做法:历史点队列和链表节点跟随。这两种方案各有各的适用场景,理解它们的差异,比直接抄代码重要得多。
2.1 历史点队列:让整条蛇严格走在头部轨迹上
历史点队列的原理很简单:维护一个List<Vector2Int>,每次蛇头移动之前,把当前所在的网格坐标存入这个列表。这样这个列表就记录了蛇头走过的所有格子。渲染身体节点时,第i节身体直接取历史点列表里从尾部往前数第i个点即可。
这个方案最核心的优点是:身体节点永远严格走在蛇头走过的路径上。不管是直角转弯还是连续折返,身体都不会出现“抄近道”或“漂移”的现象,因为它本身就是按蛇头的足迹排布的。
我在项目里用到的核心逻辑大致是:
csharp复制private List<Vector2Int> history = new List<Vector2Int>();
private Vector2Int headGridPos;
private Vector2Int headDirection;
void Move() {
history.Add(headGridPos);
headGridPos += headDirection;
for (int i = 0; i < bodyList.Count; i++) {
int idx = history.Count - 1 - i;
if (idx >= 0) {
Vector2Int targetGrid = history[idx];
bodyList[i].transform.position = new Vector3(targetGrid.x, targetGrid.y, 0);
}
}
int maxHistory = snakeLength + 2;
while (history.Count > maxHistory) {
history.RemoveAt(0);
}
}
需要特别注意历史点的裁剪。如果你不限制history的长度,它会随着游戏时间无限增长,内存占用越来越多。裁剪的标准很简单:蛇身长度为snakeLength,那么最多保留snakeLength + 2个历史点就够了,再多就是冗余数据。
提示:在初始化游戏时,记得先手动填充初始历史点。比如蛇头在(5,5),初始蛇身长度是3,那
history初始就应该包含(5,3)、(5,4)两个点。否则第一次移动时,身体节点会因为索引越界而报错。
2.2 链表节点跟随:实现直观,但转弯时会漂移
链表节点跟随是另一个常见方案:每个身体节点保存对前一个节点的引用,每一帧或者每一次移动时,让当前节点移动到前一个节点之前所在的位置。用代码表示就是:
csharp复制for (int i = bodyList.Count - 1; i > 0; i--) {
bodyList[i].transform.position = bodyList[i - 1].transform.position;
}
bodyList[0].transform.position = head.transform.position;
这个方案的优点是代码非常短,理解起来也直观。节点少的时候,性能没有任何问题。但它有一个明显的视觉缺陷:身体会漂移。
想象一下蛇头向右移动,然后突然向上转。如果采用链表跟随,第二节身体会直接斜着滑到蛇头的旧位置上,而不是走一个直角路径。对于追求经典网格感的贪吃蛇来说,这种“斜切”看起来非常不自然,甚至会让人感觉蛇身“滑”过去了。如果你做的是不限制网格的像素风或者自由移动风格游戏,这个方案反而很合适,因为身体本来就在连续移动。
2.3 我最终采用的做法:离散网格 + 历史点采样
经过对比之后,我选择了“逻辑层离散网格 + 表现层历史点采样”的组合。
具体来说:
- 逻辑层:所有状态都使用
Vector2Int网格坐标,不用transform.position参与逻辑判断。 - 表现层:身体节点直接定位到历史点对应的格子坐标;头部节点也定位到网格坐标。
- 移动方式:保持经典的跳格子移动,不做插值平滑,这样能最大程度避免视觉和逻辑不一致的问题。
为什么不加平滑?因为我发现,一旦给头部加了插值移动,就必须给所有身体节点也做插值,否则蛇身会“撕裂”。而全链路插值会引入一个麻烦:碰撞检测必须严格基于逻辑坐标,但视觉上身体已经走了一半路,玩家看到的效果和实际判定之间存在偏差。对于贪吃蛇这种小项目,保持经典跳格子反而是最稳妥的。
下面这个表格是我当时做的对比,方便你快速参考:
| 维度 | 历史点队列 | 链表节点跟随 |
|---|---|---|
| 实现难度 | 稍高,需要管理列表索引 | 极低,几行代码 |
| 身体是否严格沿头部轨迹 | 是 | 否,转弯时会斜切 |
| 生长逻辑 | 自然,新节点从尾部生成 | 需要额外处理出生位置 |
| 性能开销 | 需要维护历史列表 | 较低 |
| 适合场景 | 网格制、经典贪吃蛇 | 自由移动风格、休闲游戏 |
如果你做的是经典网格贪吃蛇,我更推荐历史点队列。它的实现只比链表方案多十来行代码,但解决掉了最让人头疼的“蛇身漂移”问题。
3. 输入与移动计时:方向缓冲、禁止反向和变速控制的坑
输入模块看着简单,实际上是整个项目里坑最多的部分。玩家不会按照你的逻辑乖乖地“一次按一个键”,他们会快速连按、同时按两个键、甚至在一帧之内连续按好几个方向。这些边界情况如果不处理,蛇的移动就会非常诡异。
3.1 方向缓冲队列:避免快速连按导致输入被吞
先看一个典型问题。假设蛇的移动间隔是0.16秒,玩家在两次移动之间快速按了“上”和“左”。如果你用Input.GetKeyDown配合一个currentDirection字段,第二次按键会直接覆盖第一次。结果就是玩家明明按了“左”,但因为“左”发生在一帧之内很晚的时间点,还没来得及被读取,就被下一次移动逻辑用掉了,看起来就像“左”被吞了。
解决这个问题的方法是引入方向缓冲队列。把所有待处理的方向输入放到一个List<Vector2Int>里,每次移动时只取队首,处理完再移除:
csharp复制private List<Vector2Int> directionBuffer = new List<Vector2Int>();
void Update() {
Vector2Int inputDir = ReadInput();
if (inputDir == Vector2Int.zero) return;
Vector2Int lastDir = directionBuffer.Count > 0
? directionBuffer[directionBuffer.Count - 1]
: headDirection;
if (inputDir == lastDir || inputDir == -lastDir) return;
directionBuffer.Add(inputDir);
if (directionBuffer.Count > 3) {
directionBuffer.RemoveAt(0);
}
}
队列长度限制为3就够用了。玩家在0.16秒内最多能产生几次有效输入?正常人手速最快也就每帧一次,缓冲3个方向已经完全够用。如果队列太长,反而会导致“按了方向键但蛇要等好几个移动周期才反应”,体验很差。
3.2 禁止反向比看起来复杂:队列里也可能埋雷
禁止反向是贪吃蛇的硬性规则:蛇不能直接掉头冲向自己的身体。最简单的判断是:
csharp复制if (newDirection == -headDirection) return;
但用了方向缓冲队列之后,问题会变得微妙。假设蛇正在向右移动,玩家快速按了“左、上、右”三个键。如果入队时不检查反向,队列里会依次存下“左、上、右”。下一次移动时,蛇会先尝试执行“左”,发现是反向,忽略;然后执行“上”,变成向上;再下一次执行“右”,此时蛇头已经朝上了,所以“右”合法,蛇向右转。结果就是玩家的三次输入有效执行了两次,虽然没撞死,但操作意图已经被扭曲了。
更严重的情况是:“左、上、左”这种组合。如果只检查“新输入是否与当前方向反向”,那么两次“左”都会被忽略吗?不一定。因为入队时“上”已经改变了队列的末尾方向,第二个“左”相对“上”并不是反向,会被加入队列;但等到执行时,蛇头可能已经执行了“左”,而队列里还有一个“左”……逻辑就乱套了。
我的处理办法是:入队时和出队时都做一次反向校验。入队时用“当前实际移动方向”和“队列最后一个方向”双重判断,出队时再校验一次“相对当前方向是否反向”,双保险。
提示:方向判断一定要用
Vector2Int,别用Vector2。浮点向量在做相等判断和反向判断时可能因为精度问题出现意外结果,Vector2Int是整数运算,可靠得多。
3.3 移动计时与加速:Update累加比协程更好控制
贪吃蛇的移动是离散的,需要一个计时器来控制“每隔多少秒移动一格”。最常见的两种写法是协程和Update累加。
我一开始用的是协程:
csharp复制IEnumerator MoveLoop() {
while (true) {
Move();
yield return new WaitForSeconds(moveInterval);
}
}
但很快发现协程在暂停、恢复、变速时很麻烦。如果游戏需要暂停,你得保存协程状态或者用StopCoroutine,重新开始又得再启动。而且moveInterval每次吃食物都会变化,协程一旦进入WaitForSeconds,旧的等待时间不会自动取消。
用Update累加会直观很多:
csharp复制private float moveTimer;
void Update() {
if (state != GameState.Playing) return;
moveTimer += Time.deltaTime;
if (moveTimer >= moveInterval) {
moveTimer -= moveInterval;
MoveAndCheck();
}
}
暂停时只需要把state切走,计时器自然就不累加了。变速时直接修改moveInterval,下一次移动立即生效。
加速逻辑我采用的是:初始间隔0.16秒,每吃一个食物减少0.005秒,最低不低于0.08秒。这样前期加速体感明显,后期逐渐趋于稳定,不会快到无法操作。
csharp复制moveInterval = Mathf.Max(0.08f, moveInterval - 0.005f);
4. 碰撞、自碰和食物生成:边角条件才是真正的难点
贪吃蛇的状态判断看着简单,但实际写起来你会发现,真正的难点全在边界情况。蛇头撞墙、蛇头撞自己的身体、食物生成在蛇身上、蛇占满地图……这些场景如果每个都单独处理,代码很快就会变得混乱。
4.1 逻辑坐标与视觉坐标必须分开
这是我在这个项目里最想强调的一点:逻辑判断永远使用网格坐标,不要直接用transform.position。
原因很现实:transform.position是浮点数,哪怕你移动时故意设置了整数坐标,经过旋转、缩放、父节点变换后,它也可能变成小数。就算你只用平移,浮点累加误差也会导致坐标变成类似(5.000001, 3.999999)的值。拿这种坐标去比较是否碰撞,结果完全不可控。
所以我定义了一组Vector2Int作为逻辑坐标,游戏状态、碰撞检测、食物生成全部基于这组整数坐标;transform.position只在最后渲染时同步一次。同步时机是移动完成后:
csharp复制transform.position = new Vector3(headGridPos.x, headGridPos.y, 0);
这样逻辑和视觉彻底解耦,即使后续要改成3D视角或者加入相机跟随,也不用动逻辑层的代码。
4.2 自碰检测的时机,直接决定Bug是否出现
自碰检测的时机非常关键。常见的错误是在蛇头移动之前检测,或者在移动之后但身体节点还没更新时检测。这两种情况都会导致漏判或误判。
正确顺序是:
- 读取方向缓冲,更新蛇头方向。
- 将当前蛇头坐标存入历史点。
- 蛇头网格坐标加上方向,得到新坐标。
- 判断新坐标是否撞墙。
- 判断新坐标是否与身体任何一个节点重合(从第1节开始,不含蛇头旧位置)。
- 如果没有碰撞,更新身体节点位置,处理生长逻辑。
如果顺序错误,比如先更新身体位置再检测自碰,那么在转弯瞬间,身体第一节还停在蛇头旧位置,蛇头新位置恰好等于蛇头旧位置,就会被误判为自碰。反过来,如果蛇头移动后但身体没更新,那么蛇头可能已经“穿过”了一个身体节点,但检测时身体节点还停在上一帧的位置,导致漏判。
我在这里踩过一次坑:我在移动顺序里把“身体跟随”和“碰撞检测”搞反了,结果蛇每次转弯碰到自己第一节身体都判死,但直行撞到自己中段却又没事。排查了很久才发现是检测时机的问题。
4.3 食物生成:随机、避重叠和死循环保护
食物生成看似简单,实际上也有很多细节。最常见的问题是在生成食物时,食物坐标恰好和蛇身重叠。
最直接的做法是循环判断:
csharp复制Vector2Int foodPos;
do {
foodPos = new Vector2Int(
Random.Range(0, gridWidth),
Random.Range(0, gridHeight)
);
} while (IsOccupiedBySnake(foodPos));
但这里有两个坑。
第一个是Random.Range的区间。Random.Range(0, gridWidth)和Random.Range(0f, gridWidth)是两套逻辑:整数重载是“左闭右开”,浮点重载是“左闭右闭”。如果你写成Random.Range(0, gridWidth),实际取值是0到gridWidth-1,这是正确的;但如果有人把其中一个写成gridWidth + 1,食物就会生成在地图外。
第二个是死循环保护。如果蛇身占据了大半个地图,do...while可能循环很多次才找到空位;极端情况下蛇身几乎占满地图,循环可能无限执行。所以一定要加最大尝试次数:
csharp复制int attempts = 0;
do {
foodPos = new Vector2Int(Random.Range(0, gridWidth), Random.Range(0, gridHeight));
attempts++;
} while (IsOccupiedBySnake(foodPos) && attempts < 10000);
if (attempts >= 10000) {
// 地图几乎被占满,可以继续尝试或判定胜利
}
更高效的做法是维护一个HashSet<Vector2Int> occupied,每次蛇移动时更新它。这样判断食物是否重叠的复杂度从O(n)降到了O(1)。不过对于20x20的小地图,遍历蛇身也足够快,所以这不是性能问题,更多是代码洁癖问题。
4.4 游戏状态机与UI切换
当碰撞发生时,游戏不是“立刻结束”这么简单,中间可能还有“吃到食物”、“得分”、“播放音效”、“弹出结束面板”等一连串连锁反应。为了不让这些逻辑散落在各处,我引入了一个简单的状态机:
csharp复制public enum GameState {
Ready,
Playing,
Paused,
GameOver
}
Update里的输入检测和移动计时只在Playing状态下执行。Ready状态等待玩家按下开始键;GameOver状态下按下重开键就重置所有数据,回到Playing。
UI部分我用的是CanvasGroup来控制面板的显示与渐隐。GameOver时不需要突然弹出面板,用CanvasGroup.alpha做0.3秒的淡入,视觉上会舒缓很多。这也是很多人问过的“unity脚本控制逐渐消失”问题,本质就是CanvasGroup.alpha的插值,和物体本身的生命周期无关。
分数刷新用TextMeshProUGUI,每次吃到食物时调用UpdateScore(score)就完事。注意TextMeshPro是单独的包,如果你创建的是旧版UI项目,需要记得导入TMP Essentials,否则字体会显示成粉色方块。
5. 优化与代码结构:这个项目值得养成的几个好习惯
贪吃蛇项目虽小,但如果不注意代码结构,很快会变成一坨“面条代码”。我在写的过程中踩过不少坑,最后总结出几个很值得养成的习惯。
5.1 用对象池管理身体节点,而不是频繁Instantiate
贪吃蛇在吃食物时需要创建新的身体节点,死亡重开时又需要销毁全部节点。如果直接用Instantiate和Destroy,短时间内大量创建销毁会让Unity的GC(垃圾回收)压力变大,玩到后期可能会感到轻微卡顿。
解决方案是用对象池。预创建一批身体节点,存到一个Queue里,需要时取出,不使用时回收隐藏:
csharp复制public class BodyPool : MonoBehaviour {
[SerializeField] private GameObject bodyPrefab;
private Queue<GameObject> pool = new Queue<GameObject>();
void Awake() {
for (int i = 0; i < 20; i++) {
GameObject go = Instantiate(bodyPrefab);
go.SetActive(false);
pool.Enqueue(go);
}
}
public GameObject Get() {
if (pool.Count > 0) {
GameObject go = pool.Dequeue();
go.SetActive(true);
return go;
}
return Instantiate(bodyPrefab);
}
public void Return(GameObject go) {
go.SetActive(false);
pool.Enqueue(go);
}
}
重开游戏时,把所有节点Return回池里即可。这样整个游戏过程中Instantiate和Destroy的次数会大幅减少,GC压力也随之降低。虽然贪吃蛇规模下影响不一定很明显,但养成用对象池的习惯,对以后做弹幕、粒子、子弹这类高频生成销毁的游戏帮助非常大。
5.2 脚本拆分:把“大杂烩”变成多个小管理器
我最初写贪吃蛇时,只写了一个GameManager脚本,输入、移动、食物、UI全塞里面。结果就是脚本越来越长,改一个功能要担心会不会破坏另一个功能。
后来我把它拆成了几个脚本,每个脚本只干一件事:
| 脚本 | 职责 |
|---|---|
| GameManager.cs | 游戏状态切换、分数管理、开始/结束流程 |
| SnakeController.cs | 蛇头移动、方向缓冲、身体跟随、自碰检测 |
| FoodSpawner.cs | 食物生成、判断生成位置是否与蛇身重叠 |
| UIManager.cs | 分数刷新、面板显示、按钮事件绑定 |
| BodyPool.cs | 身体节点的对象池管理 |
拆分之后,最明显的感受是排查Bug变得简单了。比如食物生成在蛇身上,我只用看FoodSpawner;蛇头转向失灵,只看SnakeController。这个习惯对后续做任何项目都有帮助,Unity面试时如果被问到“如何设计一个贪吃蛇”,脚本职责划分清楚绝对是个加分项。
5.3 后续扩展:从2D到3D、双人模式、AI自动寻路
做完基础版之后,你完全可以把它往不同方向扩展,这些扩展也能帮你加深理解。
最直接的扩展是改3D。把地面
