先别急着到处找教程,也别一上来就啃引擎官方文档。很多人第一次用Unity做小游戏,问我的第一句话通常是“到底做什么才能快速上手”。我一般只推荐一个答案:贪吃蛇2D。原因很简单,它足够小,但该有的东西一样不少。小到你一个周末能做完,大到能把你学习游戏引擎时最需要的那几条关键链路全部走一遍:场景搭建、素材挂载、脚本控制、输入响应、碰撞判定、UI刷新,最后还能打包成一个别人能直接双击打开的窗口程序。
一个零基础的人,拿着Unity做出能玩的贪吃蛇,并不仅仅是“做了一款小游戏”。这个过程相当于用最轻量的项目,把游戏开发的完整流程在脑子里建立了一张地图。等你做完它,再去看其他教程,会发现很多原来听不懂的概念,比如组件、预制体、游戏循环、场景生命周期,都变得特别具体。这篇文章就是围绕“用Unity做贪吃蛇2D”这件事,把我实际操作中的思路、代码、踩坑和排查方法完整写出来。适合完全没碰过Unity的人,也适合跟着教程做过一些案例但始终没弄明白原理的人。
1. 为什么第一个Unity项目要做贪吃蛇
1.1 一个小游戏覆盖的完整开发闭环
很多人觉得贪吃蛇太简单,看不起它。可你真的把它拆开看,它已经包含了商业游戏最核心的几个模块:玩家输入、按时间推进的游戏逻辑、对象生成与销毁、碰撞检测、状态切换、计分与UI显示。这几件事,就是你以后做任何游戏都要反复面对的东西。
比如玩家输入,你写“按方向键让蛇头转向”,本质上就是处理输入映射;你写“吃到食物加一分”,本质上就是对象碰撞与事件触发;你再写一个“撞墙游戏结束”的判断,那就是游戏状态管理。这些东西换到跑酷、射击、甚至休闲养成游戏里,骨架几乎一模一样。区别只是表现层更花哨,规则更复杂,底层逻辑反而是相通的。
用Unity做这样一个项目,还能顺便理解几个引擎层面的核心概念。一个是GameObject和Component的关系。蛇头是个空物体,挂上脚本组件就能动;食物是个Sprite,挂在场景里就能被渲染;UI文字是Canvas下的文本组件,脚本里改它的text属性,画面就变了。另一个是关键的游戏循环思想。游戏不是一帧之内把全部事情做完,而是每一帧做一小步:读取输入、累加时间、时间到就移动、移动后判断碰撞、碰撞后更新分数。把这个循环印在脑子里,后面学什么功能都顺。
1.2 零基础开始前,只要准备这几样
首先你要装一个Unity编辑器。这里强烈建议用Unity Hub来管理版本,别直接下载一个编辑器装完就完事。因为以后你可能需要同时维护多个项目,不同项目用的Unity版本不一样,Unity Hub能帮你一键切换。版本选LTS(长期支持版)就行,比如2022.3 LTS。引擎版本不用追新,稳定是第一位的。
然后是C#的基础语法。别被“编程语言”这四个字吓到,做贪吃蛇只需要用到变量、方法、if判断、List列表、枚举和简单的类。这些内容在任何一个C#入门教程的前几章就能学完。你不需要理解委托、事件、泛型高级用法,甚至不需要会写类,能看懂代码结构,会抄会改,就足够完成这个项目。
素材方面更不需要美术资源。Unity自带的Sprite功能就能生成纯色方块,蛇头用绿色方块,身体用蓝色方块,食物用红色方块,背景用深色就行。真要用一张图片,拿画图工具做一个64x64的白色方块导出PNG,拖进Unity就能用。声音也可以完全省略,等核心玩法跑通了再考虑音效。
最后一件事是耐心。贪吃蛇做起来不难,但中途一定会遇到脚本报错、物体不显示、方向不对这类问题。这些不是失败,是正常的排查过程。后面我会把最常见的问题和处理方法全部列出来,你照着对照就能解决大半。
2. 项目设计与方案选型
2.1 把玩法拆成可实现的模块
任何项目开工前,先别急着打开编辑器,花十分钟把玩法拆清楚。贪吃蛇的核心玩法可以拆成下面几个模块:蛇的移动、蛇吃食物、蛇生长、碰撞失败、计分。每个模块又对应若干个小问题。
蛇的移动,实际上是“每隔固定时间朝当前方向走一格”。蛇头走一格之后,身体的每一节跟着往前走一格,而蛇尾方向丢掉一格。所以整条蛇本质上是一条按时间推进的坐标队列。蛇吃食物,本质上是蛇头坐标和食物坐标重合时,触发一次“增长”标记。因为蛇的移动是走一步掉一格,只要这一轮不掉尾,蛇身就变长了。碰撞失败,就是蛇头即将到达的新坐标,要么超出游戏边界,要么已经存在于蛇身坐标中。计分就是每次吃到食物,给分数变量加一,再刷新一行UI文字。
这样拆完,你会发现一个很清晰的开发顺序:先让蛇能移动,再让蛇能跟随头移动,再做食物生成与吃食物,最后做碰撞判定和UI。顺序不能乱。很多人一上来就做菜单、做音效、做酷炫界面,结果核心玩法没完成,项目直接烂尾。正确策略永远是先用最简单的白色方块把玩法跑通,再加花活。
2.2 移动方案:为什么别用物理系统
刚接触Unity的人常有个误区:物体移动就要加Rigidbody2D,碰撞就要用Collider2D。但贪吃蛇如果真用物理引擎做,反而会把项目搞复杂。物理引擎适合模拟连续运动和真实碰撞,比如小球弹跳、角色受重力影响。而贪吃蛇要求的是严格的网格化离散移动,一次走一格,不需要弹力,不需要摩擦力,更不需要速度衰减。
我见过有人用Rigidbody2D蛇身加碰撞体,结果蛇的速度不好控制,身体节点之间还会互相推挤,调了一个晚上都调不出“经典贪吃蛇”的节奏感。原因就是物理系统天然是连续的,会引入力和加速度,而你根本不需要它们。
正确的做法是:用脚本维护一个坐标列表,然后用Transform组件直接设置位置。Transform控制位置是Unity最基础的能力,说白了就是把物体放到世界坐标的某个点上。每走一步,我们把新坐标计算出来,让蛇头移动到那个坐标,身体节点依次跟上,逻辑完全可控,速度调整就是改一个时间间隔变量。没有物理层参与,就不会有不可预料的抖动和碰撞反馈。
2.3 蛇的数据结构:用列表还是链表
贪吃蛇的蛇身,本质是一个“先进先出”的队列。蛇头位置不断入队,蛇尾位置不断出队。新人第一反应是用数组,但数组的长度不能动态变化,不好用。链表的想法是完全正确的,但C#里你未必需要自己手写链表,用List 就能满足需求。
List是C#内置的可变长列表,你可以随时往头部插入坐标,也可以随时从尾部移除坐标。具体来说,每走一步,计算蛇头的新坐标,把新坐标插入到列表最前面,然后如果没有吃到食物,就把列表最后一个元素删掉,这就是蛇身的全部秘密。为什么不用双向链表?因为贪吃蛇长度最多几十节,List的插入和删除在数据量这么小的情况下性能差异几乎为零,但代码可读性高很多,零基础也容易理解。
另外,列表里存坐标而不是直接存游戏对象。原因很简单:蛇身节点对象可能需要创建和销毁,如果用坐标列表统一管理,你循环一次就能把所有节点位置更新到位,不会出现“尾巴没跟上”的错乱。这也是后面排查身体穿插问题时的关键思路:一个数据源,多个表现对象,表现永远跟随数据走。
2.4 场景与素材的规划
新建一个2D项目后,Unity会自动生成一个Main Camera,并且这个相机的投影模式是正交投影。2D游戏用正交相机,意思是物体没有近大远小的透视效果,适合表现平面棋盘。相机默认位置在(0,0,-10),朝向Z轴看过去,场景里的物体显示在X-Y平面上,这一步不需要改。
接着规划游戏区域。你不需要建一个特别大的地图,20x20的格子就足够玩。为了便于后续逻辑判断,我给游戏区域设成从-10到10,横向和纵向都是20个格子。贪吃蛇初始长度3格,出生在中间偏左的位置,初始方向朝右。这些数值都可以先写成固定值,后面再改成变量,方便你调整难度。
素材规划方面,最少需要三种对象:蛇头、蛇身、食物。这三种对象都用Sprite渲染同一个方块图片,只是颜色不同。场景里还有一个边界框,可以用几个细长的方块拼出来,或者干脆不做边界物体,直接用坐标判断是否越界。我的做法是不做实际边界墙,因为网格边界用数字判断更简单,后面讲解碰撞时你会看到理由。UI部分需要一个Canvas,里面放一个文本用于显示分数。
3. 实操搭建:从新建项目到蛇会动
3.1 新建项目与基础环境设置
第一步,打开Unity Hub,点击“新建项目”。模板选择2D Core,也就是内置渲染管线的2D模板。如果你看到“2D (URP)”也不要怕,URP是更现代的渲染管线,但零基础用内置2D模板更省心,少遇到一些管线报错。项目名称可以叫Snake2D,位置选一个容易找的目录。
项目打开之后,先看一下左侧的Hierarchy窗口,里面默认只有Main Camera。点击Main Camera,在Inspector面板里确认Projection是Orthographic,然后找到Camera组件的Background属性,把默认蓝色改成深灰色,比如RGB(20, 20, 20)。这样游戏背景就是一个干净的深色棋盘,比蓝色看着舒服,也不刺眼。
接下来创建纯色方块素材。在Project窗口里右键,选择Create -> Sprite -> Square,它会生成一个内置的方形Sprite。把这个Sprite拖到Hierarchy窗口,场景里就会出现一个白色方块。因为Unity坐标里1单位等于1米,而这个方块默认就是1x1,正好是我们想要的格子大小。我习惯把这种原始方块命名为“CellBase”,后面蛇头、蛇身、食物都复制它,再改颜色。
为了让场景里物体显示更稳,给蛇头和蛇身对象都加上Sorting Order设置。选中对象,找到Sprite Renderer组件里的Sorting Order,数值大的会显示在前面。食物设为10,蛇身设为5,背景设为0。这个数值不是必须精确,只要保证食物不会被蛇身盖住就行。
3.2 搭建游戏边界和蛇身预制体
我不建议手动画20x20个方块来当网格,那太傻。我们要做的重点是三个预制体:蛇头预制体、蛇身预制体、食物预制体。预制体的意思是“预先制作好的物体模板”,运行时可以反复创建实例。
先从场景里的CellBase做起。把它重命名成SnakeHead,在Sprite Renderer组件里把Color改成绿色,然后把Project窗口里的CellBase素材拖回Hierarchy创建了一个新物体SnakeBody,改色为蓝色。再创建一个新物体Food,改色为红色。这三个对象准备好之后,分别从Hierarchy拖到Project窗口的Assets区域,Unity会自动生成三个预制体。生成后可以把场景里的临时对象删掉,因为运行时都由脚本动态创建。
还需要准备一个空物体叫GameManager,用来挂载游戏主控制脚本。为什么特意用空物体?因为游戏主逻辑不属于任何一个可见物体,它更像总指挥,空物体最合适。在Hierarchy右键,选择Create Empty,重命名GameManager,位置保持(0,0,0)即可。
边界问题我决定用纯坐标判断,不建墙。这样好处是逻辑简单,而且不会出现蛇头顶着墙但还往前滑一小段的情况。等基础版完成,你想加视觉效果,再补一圈装饰性墙块就行。
3.3 核心移动逻辑:定时器驱动的蛇头移动
接下来进入最核心的代码环节。在Project窗口右键,选择Create -> C# Script,命名为SnakeController。注意C#脚本名字必须和类名一致,否则Unity编译报错。把脚本挂到GameManager上。
打开脚本,先清理默认的Start和Update模板,然后按下面的代码写。不需要完全照着抄,但要理解每一句话在做什么。
using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; public class SnakeController : MonoBehaviour { public float moveInterval = 0.15f; public GameObject snakeHeadPrefab; public GameObject snakeBodyPrefab; public GameObject foodPrefab; public Transform foodObject; public Text scoreText; private List<Vector2> snakePositions = new List<Vector2>(); private List<Transform> snakeBodyTransforms = new List<Transform>(); private Vector2 currentDirection = Vector2.right; private Vector2 nextDirection = Vector2.right; private float timer = 0f; private bool isGameOver = false; private bool shouldGrow = false; private int score = 0; private void Start() { snakePositions.Add(new Vector2(0, 0)); snakePositions.Add(new Vector2(-1, 0)); snakePositions.Add(new Vector2(-2, 0)); SpawnBody(); SpawnFood(); } private void Update() { if (isGameOver) return; ReadInput(); timer += Time.deltaTime; if (timer >= moveInterval) { timer = 0f; Move(); } } }解释一下移动控制。我们没有在Update里每帧移动蛇头,而是用timer累计真实时间。moveInterval是两次移动之间的间隔,设置成0.15秒左右,蛇走起来节奏就比较接近经典贪吃蛇。这个间隔越大,蛇越慢;间隔越小,蛇越快。后面你想做加速功能,就是把这个变量逐步调小。
为什么要用timer累计而不是每帧都移动?因为Update每秒会执行几十次甚至上百次,如果每帧都移动,蛇会快得完全没法玩。游戏开发中这种“固定间隔执行一次”的需求非常常见,动作游戏里的连击判定、格斗游戏里的受击硬直,都是类似思路。
3.4 身体跟随的两种实现方式
身体跟随有两种常见写法。第一种是“沙漏式”:每一节身体都存成Transform,移动时让第i节移动到第i-1节的旧位置,循环一遍。第二种是“坐标快照式”:维护一个完整的坐标列表,每次移动先更新坐标列表,再根据列表刷新所有身体节点的位置。我推荐第二种,因为数据更集中,不容易错位。
实现代码如下,把Move函数补完整:
private void Move() { Vector2 newHead = snakePositions[0] + currentDirection; bool growNow = shouldGrow; shouldGrow = false; if (IsOutOfBounds(newHead) || IsSelfCollision(newHead, growNow)) { GameOver(); return; } snakePositions.Insert(0, newHead); if (!growNow) { snakePositions.RemoveAt(snakePositions.Count - 1); } SyncBody(); CheckFood(); }这里蛇的移动方向currentDirection是一个单位向量。Vector2.right是(1,0),加在蛇头坐标上,就代表向右走一格。如果改成Vector2.up就是向上。因为格子大小是1,坐标直接加减单位向量即可。
Update里每帧先读输入,再累加时间,时间到了才执行Move。这样移动是离散的,看起来像“跳格子”,但间隔足够短时,人眼就会觉得是连续运动。这个方法还能保证蛇的移动节奏稳定,不受帧率波动影响。
SyncBody函数负责让场景里的身体对象匹配snakePositions列表:
private void SyncBody() { for (int i = 0; i < snakePositions.Count; i++) { if (i < snakeBodyTransforms.Count) { snakeBodyTransforms[i].position = snakePositions[i]; } else { GameObject body = Instantiate(snakeBodyPrefab, snakePositions[i], Quaternion.identity); snakeBodyTransforms.Add(body.transform); } } }这个循环的意思是:第0个位置是蛇头,第1个位置是第一节身体,依次对应。场景里没有那么多身体节点时,就新建一个;已经有的,直接把位置设置成列表里的坐标。
为什么强调“先更新数据,再刷新表现”?因为如果你直接移动场景里的物体,顺序稍有差错,身体就会拧成一团。而坐标列表是权威数据,场景节点只是它的“表现形式”。这一条思维以后做任何带跟随效果的东西都用得上。
3.5 输入处理与防反向
输入部分看起来简单,但坑不少。最经典的贪吃蛇翻车方式是:蛇向右走的时候,你连按两下“上”和“左”,按得太快,蛇可能直接反向钻进自己的身体。
我先写一个适合零基础理解的版本:
private void ReadInput() { if (Input.GetKeyDown(KeyCode.UpArrow) && currentDirection != Vector2.down) { nextDirection = Vector2.up; } else if (Input.GetKeyDown(KeyCode.DownArrow) && currentDirection != Vector2.up) { nextDirection = Vector2.down; } else if (Input.GetKeyDown(KeyCode.LeftArrow) && currentDirection != Vector2.right) { nextDirection = Vector2.left; } else if (Input.GetKeyDown(KeyCode.RightArrow) && currentDirection != Vector2.left) { nextDirection = Vector2.right; } }然后在Move函数里,移动前先执行:
currentDirection = nextDirection;这个版本有个小bug:如果在一次Update里快速按了两个方向键,比如先按“上”再按“左”,只会在两次Update之间处理,一般不至于立刻反向。但它有一个隐患,就是当蛇向右走时,你按一次“上”,此时currentDirection还是右,nextDirection改成上;还没等蛇真正转向,你紧接着又按一次“左”,此时currentDirection还是右,检查通过,nextDirection变成了左。于是蛇从右直接转向左,就撞上了自己正在右侧的身体。
要彻底解决这个问题,需要给输入加一个“方向缓冲队列”。原理是按下方向键时,不要立即当成最终方向,而是放进一个队列,Move执行到的时候再逐条取出。队列长度也可以限制,比如最多存2条,超出丢弃。这样快速连按时,蛇会先执行上,再执行左,不会直接反向。
方向缓冲代码:
private Queue<Vector2> directionQueue = new Queue<Vector2>(); private void ReadInput() { Vector2 newDir = Vector2.zero; if (Input.GetKeyDown(KeyCode.UpArrow)) newDir = Vector2.up; else if (Input.GetKeyDown(KeyCode.DownArrow)) newDir = Vector2.down; else if (Input.GetKeyDown(KeyCode.LeftArrow)) newDir = Vector2.left; else if (Input.GetKeyDown(KeyCode.RightArrow)) newDir = Vector2.right; if (newDir == Vector2.zero) return; if (IsOpposite(newDir, currentDirection)) return; if (directionQueue.Count > 0) { Vector2 lastQueued = directionQueue.Peek(); if (IsOpposite(newDir, lastQueued) || newDir == lastQueued) return; } if (directionQueue.Count < 3) { directionQueue.Enqueue(newDir); } }Queue是C#里的队列数据结构,先进先出。每次ReadInput把方向放进队列,Move里从队列取出一个方向并更新。因为队列里的方向和当前方向是“逐次生效”的,所以不会发生“还没转向就又反向”的问题。
对于零基础,我建议先把简单版跑通,再改成缓冲队列版。两个版本都跑一遍,你对输入处理和数据结构的感觉会完全不一样。
4. 让游戏真正可玩:食物、碰撞、分数
4.1 食物生成与防重叠校验
食物生成说白了就是随机选一个坐标,然后让食物对象显示在那里。但有一个隐藏问题:随机出来的坐标必须落在游戏边界内,并且不能在蛇身上,否则食物会生成在蛇肚子里面,非常尴尬。
实现SpawnFood函数:
private void SpawnFood() { Vector2 foodPos; bool occupied; do { int x = Random.Range(-10, 11); int y = Random.Range(-10, 11); foodPos = new Vector2(x, y); occupied = snakePositions.Contains(foodPos); } while (occupied); if (foodObject == null) { GameObject food = Instantiate(foodPrefab, foodPos, Quaternion.identity); foodObject = food.transform; } else { foodObject.position = foodPos; } }Random.Range(-10, 11)表示从-10到10的整数。注意第一个参数是包含,第二个参数是不包含,所以写-10到11才能取到10。边界是-10到10,刚好20个格子。如果你把食物生成在边界上,蛇也允许走到边界上,那就没问题。但如果你希望蛇撞到边界就死,并且食物生成在边界内一格,可以把范围改成-9到10。
为什么用do while循环?因为要反复判断随机坐标是否在蛇身上,直到找到空地为止。只要蛇身没占满整个地图,循环一定能结束。这个写法虽然简单,但偶尔会有极端情况:蛇身很长时,随机几十次才能找到一个空位,性能会受影响。到了那时候,你可以预先生成所有空地坐标列表,然后随机索引,效率更高。不过贪吃蛇的棋盘只有400个格子,试几十次根本无所谓,不用过度优化。
检查食物是否被吃掉,需要在每次移动后判断:
private void CheckFood() { if (foodObject == null) return; if (snakePositions[0] == (Vector2)foodObject.position) { shouldGrow = true; score++; if (scoreText != null) { scoreText.text = "分数: " + score; } SpawnFood(); } }这里利用了snakePositions[0]是蛇头坐标这一事实。蛇头坐标和食物坐标相同时,说明吃到了。吃到食物后,shouldGrow标记为true,下一次Move时蛇身不删除尾部,自然就长了一节。同时分数加一,更新UI文本。
4.2 吃食物与蛇身增长
吃食物的核心是增长标记。很多新手会尝试“吃到食物后,立刻从尾部复制一个节点”,这个思路其实不对。因为蛇是连续移动的,你以为复制尾部就能增长,结果往往出现身体和尾部重叠,或者长出两节。
正确逻辑是:蛇每次移动,默认都要从尾部删掉一个坐标,表示蛇向前走了一格。吃到食物后,把“这次不删尾”记录下来。下一次移动时,头照常插入新坐标,尾不删,于是整条蛇就多了一个坐标,也就是长了一节。
这个机制在Move里已经实现:shouldGrow为true时,只插入头部坐标,不删除尾部坐标。等这一轮移动结束,再把shouldGrow重置为false。有个细节要注意:shouldGrow必须在下一次移动开始时先读出来,再重置。因为如果在下一次移动前就把shouldGrow清掉了,增长就不会发生。
算法的顺序也很关键:先计算新头部坐标,判断是否碰撞,再插入头部坐标。如果你先插入坐标再做碰撞判断,蛇头已经占据了一个非法格子,判断逻辑就会混乱。数据先验证,再修改,这是游戏逻辑里很通用的习惯。
4.3 碰撞判定与游戏结束
贪吃蛇的失败条件有两个:撞到墙壁,或者咬到自己的身体。因为整条蛇运行在网格坐标上,判断起来非常直接。
private bool IsOutOfBounds(Vector2 pos) { return pos.x < -10 || pos.x > 10 || pos.y < -10 || pos.y > 10; } private bool IsSelfCollision(Vector2 pos, bool growing) { for (int i = 0; i < snakePositions.Count; i++) { if (i == snakePositions.Count - 1 && !growing) { continue; } if (snakePositions[i] == pos) { return true; } } return false; }这里我特意处理了一个玩家很少注意的细节:蛇头移动到蛇尾即将离开的格子,到底算不算撞到自己?从物理直觉上讲不算,因为蛇尾正在同时向前移动,那一格马上会空出来。如果程序仍把它判定为死亡,玩家会觉得“我明明没有撞到身体呀”。所以当目标坐标等于列表最后一个坐标,且这一轮没有增长时,要跳过它。
很多新手写自碰撞,直接从头到尾挨个对比,不考虑尾部移动,结果蛇绕圈时差一格就会莫名其妙死掉,体验特别差。处理掉这个细节,游戏手感会顺很多。
游戏结束后,我用一个简单的GameOver方法:
private void GameOver() { isGameOver = true; Debug.Log("游戏结束,得分: " + score); }这个版本只做了控制台输出。正式一点的做法是显示一个隐藏的GameOver面板,把分数显示在上面。后面扩展章节我会建议怎么做。
4.4 分数显示与UI刷新
UI部分在Unity里由Canvas系统负责。在Hierarchy窗口右键,选择UI -> Canvas,Unity会自动生成Canvas和EventSystem。Canvas模式保持默认的Screen Space - Overlay,这种模式下UI永远显示在屏幕最上层,不需要管相机位置。
在Canvas下右键,创建UI -> Text,命名为ScoreText。把位置放到屏幕左上角区域,字体大小设成24,颜色白色。在Text组件的Text输入框里先写“分数: 0”。
接下来要把这个Text拖到SnakeController脚本的scoreText字段上。方法是选中GameManager,在Inspector面板里找到SnakeController组件,看到scoreText那一栏,把Hierarchy里的ScoreText拖进去。
代码里更新分数时用:
scoreText.text = "分数: " + score;这里提一个性能习惯:UI文本不要每帧更新。因为分数只在吃到食物时变化,频繁设置文本会造成不必要的UI重绘。虽然一个Text不至于卡顿,但养成“数据变化时才刷新界面”的习惯,后面做复杂HUD时会很有帮助。
如果你用的是新版Unity,创建UI -> Text时可能需要导入TextMeshPro的Essentials资源,这是引擎提示你导入的,点击导入即可。TextMeshPro的中文显示更清晰,用起来和旧版Text差别不大,只是字段类型要改成TMP_Text。零基础如果不想处理导入,可以先选旧版Text,功能完全够用。
5. 常见问题与排查实录
5.1 一按方向键就死的反向问题
这是新手最容易碰到的经典问题。蛇明明往右走,你按一下“上”,再立刻按一下“左”,蛇没来得及转上就直接往左,一头扎进自己身体里。
原因我之前已经说过:简单输入处理只检查了“新方向不能与当前方向相反”,但没考虑方向变化需要延迟生效。解决方案就是方向缓冲队列。你先让每次移动只能从队列里取一个方向,并且入队时校验新方向不能和队列里最后的那个方向相反,这样连续快速按键也能依次生效。
如果你暂时不想写队列,还有一个妥协方案:在ReadInput里增加一个“是否已经在本帧移动前转过向”的标记。也就是每次移动之间只允许修改一次方向。但这个方法在高速模式下手感不如队列版本,建议还是用队列。
5.2 食物卡在边界外或蛇身上
食物生成后出现在看不到的地方,多半是Random.Range边界写错了。记住Random.Range如果是整数参数,第二个参数是不包含的最大值。比如你要在-10到10之间取整数,就要写Random.Range(-10, 11)。如果写成Random.Range(-10, 10),则永远不会出现10,并且范围变成了19格,和你的边界判断对不上,蛇就可能在食物永远不出现的地方活动。
食物生成在蛇身上,是因为生成时没有检查坐标是否已经被蛇占据。解决方案就是前面写的do while循环。注意一种特殊情况:吃食物那一瞬间,shouldGrow标记还没清除,SpawnFood又在Move里被调用,这时候蛇的坐标列表已经包含了新插入的蛇头,检查用的应该是这个“新状态”,而不是旧状态。我这边的实现顺序是:先插入头、删除尾、同步身体,再调用SpawnFood,所以检查是准确的。
5.3 蛇移动忽快忽慢
如果你把移动写在Update里而没有用timer,蛇的移动速度会依赖帧率。帧率高,蛇就快;帧率低,蛇就慢。在有垂直同步或笔记本省电模式下,帧率波动很明显,蛇就会忽快忽慢。
正确做法就是用Time.deltaTime累加,达到目标间隔后移动一次。这个方案的好处是“时间驱动”,无论游戏运行在30帧还是60帧,蛇的实际速度都一样。还有一个细节:如果移动间隔很小,比如0.05秒,timer累加可能会因为帧率不够而出现一次跳两格的情况。解决办法是使用while循环处理累积时间,确保不会丢失移动次数。不过贪吃蛇0.15秒的间隔,普通帧率完全够用,先用if没问题。
5.4 身体节点乱跳、穿插
身体节点乱跳,几乎都是因为更新顺序错了。常见的错误写法是:先移动第一个身体节点到蛇头旧位置,再移动第二个节点到第一个节点旧位置。但是你在移动节点的时候,旧位置已经被覆盖了,后面的节点拿到的是被修改后的位置,于是整条蛇就扭成一团。
解决方式就是坐标列表快照法:先把所有坐标计算好,更新到列表里,然后循环给每个节点设置位置。这样每个节点都从同一个快照里取坐标,不会互相干扰。如果你一定要用“逐个覆盖”的移动方式,那就得先把每个节点的旧位置存起来,再依次赋值,逻辑容易出错。
另一个常见原因:实例化身体节点后,忘记把节点的主父物体设置好。虽然不影响移动,但Hierarchy里会堆一堆散落对象,找问题时会很混乱。实例化后可以设置body.transform.SetParent(transform),当然这不是必需的。
5.5 游戏结束没有反馈
很多新手做的贪吃蛇,撞墙后蛇不动了,但玩家不知道是死了还是卡了。调试时至少要用Debug.Log打印一条信息,正式版本要显示Game Over面板。比较简单的做法是预先在Canvas里创建一个Panel,默认隐藏,游戏结束时SetActive(true)。如果嫌麻烦,可以直接用UI Text显示“游戏结束,得分: XX”,把Text从隐藏改为显示。
5.6 常见问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 蛇不动 | 脚本没挂到物体上,或moveInterval太大 | 检查GameManager是否挂了SnakeController,移动间隔调到0.2以下 |
| 蛇反向自杀 | 输入方向没有缓冲和反向校验 | 使用方向队列,入队时校验是否与当前方向或队尾方向相反 |
| 食物出现在看不见的位置 | Random.Range边界参数写错 | 整数随机范围用-10到11,确认第二个参数不包含 |
| 食物长在蛇身上 | 生成时未检查坐标是否被占据 | 用do while循环重新随机,直到位置不在snakePositions里 |
| 身体乱穿插 | 节点逐个覆盖导致位置被覆盖 | 使用坐标列表,先更新数据再批量刷新节点位置 |
| 速度时快时慢 | 移动逻辑写在Update里没有用时间驱动 | 用timer和Time.deltaTime,达到间隔再移动 |
| 分数不刷新 | scoreText字段没拖入Inspector | 在GameManager的组件上把ScoreText拖拽绑定到scoreText字段 |
| 游戏卡顿 | 每帧更新UI文本或频繁实例化食物 | 只在分数变化时刷新UI,食物用单个对象移动位置而不是反复创建销毁 |
6. 打包分享与后续扩展
6.1 打包成Windows可执行程序
做完一个游戏,不打包等于白做。在本机编辑器里能玩,和让朋友双击exe能玩,中间还隔着一步构建。
打开菜单File -> Build Settings,点击Add Open Scenes,把你当前场景加进构建列表。左侧平台列表选Windows, Mac, Linux,右下角Target Platform选Windows,Architecture选x86_64。然后点击Build,选一个输出文件夹,Unity会开始编译打包。第一次打包花的时间可能比较长,之后再打包就快了。
这里有个小提醒:写完代码后先保存场景,再进Build Settings。如果场景没保存,构建出来的是空白场景,游戏打开会黑屏或者只有一个相机。另外,Player Settings里的公司名和产品名会显示在exe属性里,改成你自己的昵称就行,不用填真实信息。
打包完成后,把输出文件夹整个压缩发给朋友,对方解压后运行exe就能玩。注意不能只发一个exe文件,因为Unity构建出来的游戏包含数据文件夹和可执行文件两个部分,两者必须在同一个目录下才能运行。
6.2 打包WebGL的注意点
如果你想让人不用下载就能玩,可以构建WebGL版。在Build Settings里切换平台到WebGL,首次切换Unity会下载WebGL相关模块,耐心等一会儿。WebGL的Player Settings里有一个Compression Format选项,建议选Disabled或Brotli,前者兼容性最好但文件大,后者体积小但对服务器环境有要求。
WebGL构建完成后会生成一个文件夹,里面包含index.html、Build和TemplateData。你可以把整个文件夹放到任意静态文件服务上,访问者打开网页就会自动加载游戏。因为WebGL版的输入、分辨率适配和加载界面与桌面版有差异,零基础第一次构建可能会因为加载时间太长以为失败了。解决办法是在Player Settings里关闭“Run In Background”,同时注意浏览器控制台报错信息,通常能很快定位。
6.3 贪吃蛇之后还能往哪扩展
做完了基础版,这款游戏还大有可玩。加最高分记录,只需要用PlayerPrefs存储一个整数,每次游戏结束时对比更新。加开始菜单,可以新增一个Scene,放标题和“开始游戏”按钮,用SceneManager.LoadScene切换场景。给蛇加速,每隔一段时间把moveInterval乘一个小系数,比如0.95,但要注意别低于0.05,否则蛇会快到无法操作。
加音效也很简单,准备一个短促的吃食物音效和一个失败音效,拖进场景,挂上AudioSource组件,在吃到食物和GameOver时调用PlayOneShot。甚至还可以做双人模式,两人各操控一条蛇,比谁活得更久,那就需要两套输入按键、两套坐标列表和两套预制体,整体代码会复杂不少,但很适合作为下一个项目挑战。
6.4 从第一个游戏里带走的三种思维
做完贪吃蛇,我希望你带走的不是一段能跑的代码,而是三种思维。第一种是“数据与表现分离”。蛇身列表是数据,场景节点是表现,数据更新完再刷新表现,很多渲染异常就不会发生。第二种是“验证后再修改”。新蛇头坐标要先验证是否合法,再插入列表,游戏逻辑的很多崩溃和奇怪Bug都来自顺序颠倒。第三种是“小步迭代”。别指望一口气把完整游戏写完,先让蛇动起来,再让蛇吃到食物,再让它死,最后美化。每一步都能跑,每一步都能验证,这才是做游戏项目最稳妥的节奏。
我自己带零基础朋友做这个项目时,最大的感慨是:很多人不是学不会,而是太急着做“大游戏”。跟完一套几十小时的教程,做完一个复杂Demo,最后发现自己什么都没记住。贪吃蛇这个规模,恰恰允许你亲手把每个环节做一遍,并且有时间去理解它。做完之后不要停在一个版本上,试着改数值、加功能、换美术,哪怕改坏了都没关系。改坏一次,你学到的东西比看十遍教程都多。用最丑的方块把玩法跑通,再一步步打磨,这句话几乎适用于你今后所有的游戏项目。