Unity贪吃蛇游戏开发:状态管理、碰撞检测与UI系统实现

📅 2026/8/10 14:58:42
Unity贪吃蛇游戏开发:状态管理、碰撞检测与UI系统实现
1. 项目概述与核心思路上次我们聊了贪吃蛇项目的基础框架搭建把蛇的移动、食物的生成这些核心循环给跑通了。这次咱们接着往下走把游戏真正“玩起来”需要的东西补全。一个完整的贪吃蛇光会动和吃可不行它得有个明确的游戏规则蛇撞到自己或者墙壁游戏结束同时我们还得知道玩得怎么样——这就需要一个计分系统。此外为了让游戏体验更完整开始界面、结束界面这些UI也是必不可少的。这一篇笔记我就来详细拆解如何为我们的Unity贪吃蛇实现游戏逻辑控制、分数统计以及简单的UI交互。你会发现把这些模块清晰地分离出来不仅让代码更好维护也能让你对Unity中如何组织一个小型游戏项目有更深刻的理解。核心目标很明确构建一个状态驱动的游戏管理器GameManager让它来统领全局的游戏状态进行中、已结束、重新开始同时处理得分逻辑和UI的显隐控制。我们会采用观察者模式的思想让各个模块蛇、食物、UI之间尽量解耦只通过GameManager这个中枢来通信。这样做的好处是未来你想加新功能比如音效、不同关卡只需要修改或扩展GameManager而不用去动蛇或者食物的代码。2. 游戏状态管理与全局控制器设计游戏不能一直无脑地运行下去它得有开始、进行和结束这几个明确的状态。在Unity里我们通常不会把控制逻辑散落在各个物体上而是创建一个单例模式的GameManager来充当大脑。这个管理器不渲染任何东西它的职责就是记住当前游戏状态并响应状态变化去指挥其他模块。2.1 创建GameManager单例首先在Scripts文件夹下创建一个新的C#脚本命名为GameManager。单例模式确保我们在场景中任何时候都能方便地访问到它而不用费力地去Find。using UnityEngine; using UnityEngine.UI; // 因为我们会用到UI组件 using UnityEngine.SceneManagement; // 用于重新加载场景 public class GameManager : MonoBehaviour { // 单例实例 public static GameManager Instance { get; private set; } // 游戏状态枚举 public enum GameState { WaitingToStart, // 等待开始显示开始UI Playing, // 游戏中 GameOver // 游戏结束 } // 当前游戏状态 private GameState _currentState; public GameState CurrentState { get { return _currentState; } private set { _currentState value; OnGameStateChanged(value); // 状态改变时触发事件 } } // 玩家当前得分 private int _score; public int Score { get { return _score; } private set { _score value; OnScoreChanged(value); // 分数改变时触发事件 } } // UI组件的引用将在Inspector中赋值 [Header(UI References)] public GameObject startMenuPanel; public GameObject gameOverPanel; public Text scoreText; public Text finalScoreText; void Awake() { // 单例初始化 if (Instance ! null Instance ! this) { Destroy(this.gameObject); } else { Instance this; } } void Start() { // 游戏初始状态等待开始 CurrentState GameState.WaitingToStart; Score 0; // 初始化UI显示 UpdateUI(); } }这里我们定义了三种游戏状态并用属性包装器来设置状态和分数这样我们就能在设置的同时自动触发一些后续处理比如更新UI。Awake方法确保了GameManager的单例特性。注意我们并没有用DontDestroyOnLoad因为贪吃蛇场景简单重新加载场景也能重置单例这没问题。如果你的游戏有多个场景且需要持久化数据那就需要考虑更复杂的单例生命周期管理了。2.2 状态切换与事件响应状态改变时我们需要做一系列事情显示/隐藏对应的UI面板、启动或停止蛇的移动、播放音效等。我们在OnGameStateChanged方法里处理这些。// 在GameManager类中继续添加方法 private void OnGameStateChanged(GameState newState) { switch (newState) { case GameState.WaitingToStart: // 显示开始菜单隐藏游戏结束界面 if(startMenuPanel ! null) startMenuPanel.SetActive(true); if(gameOverPanel ! null) gameOverPanel.SetActive(false); // 可以在这里暂停蛇的移动如果蛇已经存在 Time.timeScale 0f; // 暂停游戏物理和时间逻辑 break; case GameState.Playing: // 隐藏所有UI面板进入游戏 if(startMenuPanel ! null) startMenuPanel.SetActive(false); if(gameOverPanel ! null) gameOverPanel.SetActive(false); Time.timeScale 1f; // 恢复游戏时间 // 通知蛇开始移动可以通过事件或直接调用 break; case GameState.GameOver: // 显示游戏结束界面更新最终分数 if(gameOverPanel ! null) gameOverPanel.SetActive(true); if(finalScoreText ! null) finalScoreText.text 最终得分: Score.ToString(); Time.timeScale 0f; // 暂停游戏 break; } } // 分数改变时更新UI private void OnScoreChanged(int newScore) { if(scoreText ! null) { scoreText.text 得分: newScore.ToString(); } } // 更新所有UI用于Start初始化 private void UpdateUI() { OnScoreChanged(Score); // 状态改变方法里已经处理了面板显隐这里不需要重复 }这里我用了Time.timeScale来控制游戏的暂停与继续。这是一个非常方便的方法设置为0时所有基于Time.deltaTime的移动和协程都会停止。但要注意它不影响Update和FixedUpdate方法的调用只是deltaTime会变成0。对于贪吃蛇这种回合制或网格移动的游戏你也可以不用Time.timeScale而是通过一个布尔变量在蛇的移动逻辑里控制是否执行移动命令。两种方式都可以Time.timeScale更全局控制简单。注意Time.timeScale 0会暂停所有受时间缩放影响的动画、粒子、物理和协程使用WaitForSeconds的。如果你的游戏里有不受时间缩放影响的逻辑比如UI动画需要特别注意。3. 蛇的死亡检测与游戏结束判定蛇的死亡条件是贪吃蛇游戏的核心规则之一撞墙或撞到自己。我们需要在蛇的移动过程中实时检测这些碰撞。3.1 撞墙检测边界检测假设我们的游戏区域是一个固定的网格范围。我们可以在SnakeMovement脚本或者一个专门的SnakeCollision脚本里每次蛇头移动后检查其位置是否超出了预设的边界。首先定义边界。一种简单的方法是在GameManager或一个独立的GameBoundary脚本中存储最小和最大的X、Z坐标假设我们在XZ平面上游戏。// 可以在GameManager中定义或者创建一个GameSettings脚本 [Header(Game Boundaries)] public float minX -10f; public float maxX 10f; public float minZ -10f; public float maxZ 10f;然后在蛇头移动后例如在SnakeMovement的Move方法末尾添加检测// SnakeMovement.cs 中的移动方法片段 private void Move() { // ... 之前的移动身体逻辑 ... // 移动蛇头 Vector3 newHeadPosition _bodyParts[0].position _direction * _moveSpeed * Time.deltaTime; _bodyParts[0].position newHeadPosition; // 撞墙检测 if (newHeadPosition.x GameManager.Instance.minX || newHeadPosition.x GameManager.Instance.maxX || newHeadPosition.z GameManager.Instance.minZ || newHeadPosition.z GameManager.Instance.maxZ) { GameManager.Instance.GameOver(); // 触发游戏结束 return; } // ... 后续的吃到食物检测等 ... }这里我直接调用了GameManager.Instance.GameOver()我们需要在GameManager中实现这个方法。3.2 撞自身检测这是贪吃蛇经典的死法。我们需要检查蛇头的新位置是否与身体任何一节除了头部本身的位置重叠。由于我们的身体是由一系列Transform组成的列表我们可以遍历这个列表进行比较。一个关键点是如何判断“碰撞”因为我们的蛇身是由多个立方体组成的它们之间可能有间隙。一个简单有效的方法是使用Vector3.Distance计算蛇头与每一节身体的距离如果小于一个阈值比如半个身位的宽度就判定为碰撞。// SnakeMovement.cs 中添加撞自身检测方法 private void CheckSelfCollision(Vector3 headPosition) { // 从索引1开始检查跳过蛇头本身 for (int i 1; i _bodyParts.Count; i) { // 计算蛇头与当前身体部位的距离 float distance Vector3.Distance(headPosition, _bodyParts[i].position); // 假设每个身体部位的缩放是1碰撞阈值可以设为0.5半径之和 if (distance 0.5f) { GameManager.Instance.GameOver(); return; } } }然后在Move方法的撞墙检测之后调用它CheckSelfCollision(newHeadPosition);。实操心得直接比较位置是否相等在浮点数计算中并不可靠因为移动是连续的可能刚好错过。用距离阈值判断更稳健。这个阈值需要根据你的蛇身模型大小和移动速度来微调。如果蛇移动速度很快每帧移动距离大可能需要适当增大阈值否则可能出现“穿模”但没检测到碰撞的情况。3.3 在GameManager中实现GameOver逻辑现在我们需要在GameManager中提供一个公共方法让蛇和食物如果食物生成在非法位置也可能触发结束可以调用来触发游戏结束状态。// GameManager.cs 中添加 public void GameOver() { // 防止重复触发 if (CurrentState GameState.GameOver) return; Debug.Log(游戏结束最终得分 Score); CurrentState GameState.GameOver; // 这里可以触发游戏结束音效、震动等 // if (gameOverSound ! null) AudioSource.PlayClipAtPoint(gameOverSound, Camera.main.transform.position); }4. 分数系统与食物交互增强吃食物长身体和加分是紧密关联的。我们之前已经在SnakeMovement里实现了吃食物后增加身体长度的逻辑。现在需要把分数更新整合进去并且让食物被吃后通知GameManager加分。4.1 修改Food脚本触发得分事件我们不想让Food脚本直接修改GameManager的分数这样耦合太紧。更好的做法是当食物被吃时触发一个事件比如OnFoodEaten然后由GameManager来订阅这个事件并处理加分。首先在Food脚本中定义一个简单的事件使用C#的Action委托// Food.cs using System; // 需要引入System以使用Action public class Food : MonoBehaviour { // 定义食物被吃的事件可以传递一个分数值比如不同食物不同分 public static event Actionint OnFoodEaten; // 这个食物的基础分数 public int scoreValue 10; // 当蛇头触发触发器时之前的逻辑 private void OnTriggerEnter(Collider other) { if (other.CompareTag(Player)) // 假设蛇头Tag是Player { // 触发事件传递这个食物的分数 OnFoodEaten?.Invoke(scoreValue); // 然后销毁自己并在随机位置生成新食物这部分是原有逻辑 // ... 你之前写的食物重生逻辑 ... } } }4.2 在GameManager中订阅事件并处理得分在GameManager的Start或OnEnable方法中订阅这个静态事件并在OnDisable中取消订阅防止内存泄漏。// GameManager.cs void OnEnable() { // 订阅食物被吃事件 Food.OnFoodEaten HandleFoodEaten; } void OnDisable() { // 取消订阅非常重要 Food.OnFoodEaten - HandleFoodEaten; } // 处理食物被吃事件的方法 private void HandleFoodEaten(int scoreToAdd) { // 只有在游戏进行中时吃东西才加分 if (CurrentState GameState.Playing) { Score scoreToAdd; // 这里也可以触发加分音效、特效等 Debug.Log(吃到食物加 scoreToAdd 分当前总分 Score); } }这样分数逻辑就清晰了Food只负责宣布“我被吃了我值XX分”至于谁来处理加分、加多少、游戏状态是否允许加分都由GameManager这个中枢来决定。这种事件驱动的方式极大地降低了模块间的依赖。5. UI系统搭建与交互实现UI是玩家与游戏交互的桥梁。我们需要三个主要的UI面板开始菜单、游戏内HUD显示当前分数、游戏结束界面。5.1 创建Canvas与UI元素在Hierarchy中右键 - UI - Canvas。这会自动创建一个EventSystem。在Canvas下创建三个Panel分别重命名为StartMenuPanel、GameHUD、GameOverPanel。调整它们的锚点和位置使其覆盖全屏。默认状态下GameHUD激活其他两个不激活。在StartMenuPanel上添加一个Button作为“开始游戏”按钮再添加一个Text作为标题。在GameHUD上添加一个Text用于显示实时分数锚点可以放在左上角。在GameOverPanel上添加一个Text显示“游戏结束”和最终分数再添加一个“重新开始”按钮和一个“返回菜单”按钮。你的Hierarchy结构应该大致如下Canvas ├── StartMenuPanel (Active) │ ├── TitleText │ └── StartButton ├── GameHUD (Active) // 游戏进行时显示 │ └── ScoreText └── GameOverPanel (Inactive) ├── GameOverText ├── FinalScoreText ├── RestartButton └── MenuButton5.2 为UI按钮绑定事件我们需要通过代码来控制按钮的点击事件。在GameManager中我们已经有了UI组件的引用。现在创建对应的方法并绑定到按钮的onClick事件上。首先在GameManager中增加几个公共方法供UI按钮调用// GameManager.cs // 开始游戏按钮调用 public void StartGame() { if (CurrentState GameState.WaitingToStart) { CurrentState GameState.Playing; // 这里可以初始化蛇的位置、重置食物等 // 例如FindObjectOfTypeSnakeMovement().ResetSnake(); // 例如FindObjectOfTypeFoodSpawner().SpawnInitialFood(); } } // 重新开始按钮调用游戏结束后 public void RestartGame() { // 最简单直接的方式重新加载当前场景 SceneManager.LoadScene(SceneManager.GetActiveScene().name); // 注意这会重置所有对象包括单例。我们的GameManager会在Awake中重新实例化。 } // 返回主菜单按钮调用 public void BackToMenu() { CurrentState GameState.WaitingToStart; // 你可能还需要重置分数、蛇和食物状态 Score 0; // 重置蛇和食物的逻辑... }然后在Unity编辑器中选中StartButton在Inspector中找到Button组件下方的OnClick()事件列表点击“”添加一个新事件。将对象拖拽到此处选择GameManager你需要先将GameManager脚本挂到一个空物体上比如就叫GameManager然后在函数列表中选择GameManager - StartGame。用同样的方式为RestartButton绑定RestartGame为MenuButton绑定BackToMenu。5.3 在GameManager中关联UI引用最后一步将我们在场景中创建好的UI元素拖拽到GameManager脚本的对应公共字段中。选中挂载了GameManager脚本的游戏对象在Inspector中你会看到Start Menu Panel、Game Over Panel等字段将Hierarchy中对应的Panel和Text拖进去即可。现在运行游戏。你应该会看到开始菜单点击开始后菜单消失游戏开始蛇可以移动。当蛇撞墙或撞到自己时游戏结束界面弹出显示最终分数。点击重新开始按钮场景会重载一切回到初始状态。6. 代码优化与架构思考到这一步一个功能完整的贪吃蛇游戏已经实现了。但我们的代码还有优化空间让它的架构更清晰更易于扩展。6.1 使用事件中心解耦我们之前用了Food.OnFoodEaten静态事件这已经是一种解耦。但对于更复杂的游戏可能有多个事件游戏开始、游戏结束、分数变化、暂停等。我们可以创建一个全局的EventManager或使用C#的event关键字定义更多的事件让GameManager作为发布者和订阅者。例如我们可以把游戏状态改变也定义成事件// 在GameManager中定义 public static event ActionGameState OnGameStateChange; // 在CurrentState的setter中触发 private set { _currentState value; OnGameStateChanged(value); // 原有的内部方法 OnGameStateChange?.Invoke(value); // 触发静态事件供其他脚本订阅 }这样任何需要根据游戏状态做出反应的脚本比如控制背景音乐的脚本、敌人AI脚本等都可以订阅GameManager.OnGameStateChange事件而不需要持有GameManager的引用。6.2 蛇的移动与输入分离目前的SnakeMovement脚本可能既处理键盘输入又处理移动逻辑。可以考虑将它们分离SnakeInput负责监听键盘或触摸输入只输出一个方向指令。SnakeController接收方向指令根据游戏状态是否Playing决定是否更新蛇的移动方向并调用SnakeMovement进行移动。这种分离让输入逻辑更纯粹也方便未来替换输入方式比如改成手机滑屏控制。6.3 对象池管理食物和蛇身频繁地Instantiate生成和Destroy销毁食物和蛇身段在性能上是有开销的。对于贪吃蛇这种物体数量可控的游戏实现一个简单的对象池是很好的优化。对象池预先创建好一定数量的物体需要时从池中取出并激活不用时放回池中并禁用而不是销毁。这能有效减少GC垃圾回收带来的卡顿。7. 常见问题与调试技巧实录在实现上述功能时你可能会遇到一些典型问题。这里记录几个我踩过的坑和解决方法。问题1游戏结束判定不准确蛇头明明没撞上却死了。排查首先检查碰撞检测的阈值。如果阈值设置得太小而蛇移动速度很快可能蛇头在一帧内“穿过”了身体距离计算没有小于阈值。打印出蛇头和每一节身体的位置和距离观察碰撞瞬间的数据。解决适当增大碰撞检测阈值比如从0.5调到0.7。或者考虑使用Physics.OverlapSphere在蛇头位置做一个球形检测检测与蛇身碰撞盒的碰撞这可能比距离计算更精确。问题2UI按钮点击没反应。排查检查Canvas的Render Mode是否是Screen Space - Overlay或Screen Space - Camera并正确设置了Camera。World Space模式需要射线投射。检查EventSystem是否存在且唯一。有时不小心删了或者有多个。检查按钮的Interactable是否被勾选。检查按钮的Image组件的Raycast Target是否勾选必须勾选才能接收点击。检查是否有其他UI元素如图片、Panel完全覆盖在按钮上方挡住了射线。解决根据排查结果逐一修正。可以在按钮上添加一个简单的Debug.Log来测试点击事件是否被触发。问题3重新开始游戏后分数或状态没有重置。排查如果你使用SceneManager.LoadScene整个场景会重新加载所有脚本的Start和Awake会重新执行。检查GameManager的Start方法中是否正确地重置了Score和CurrentState。解决确保在Start或Awake中进行了完整的初始化。如果有些数据是静态的比如static变量它们不会随场景加载而重置需要手动在RestartGame方法中清理。问题4食物生成在了蛇的身体内部。排查食物生成逻辑只检查了位置是否在边界内没有检查是否与蛇身重叠。解决在FoodSpawner的生成食物方法中生成一个随机位置后需要遍历蛇的身体Transform列表检查该位置与每一节身体的距离是否大于安全距离比如1.0f。如果太近就重新生成一个随机位置直到找到合适的位置为止。注意要设置一个最大尝试次数避免死循环。问题5在游戏结束状态蛇还能移动。排查游戏结束时我们可能只暂停了时间Time.timeScale 0但蛇的移动逻辑如果在Update里直接修改Transform.position它不受Time.timeScale影响。或者蛇的移动是基于协程的而协程没有被正确停止。解决在GameManager的GameOver方法中除了设置状态还应直接通知蛇的控制器停止移动。可以在蛇的移动脚本里增加一个public void StopMoving()方法在里面停止所有相关的协程和更新。然后在GameOver中调用FindObjectOfTypeSnakeMovement().StopMoving();。同样在StartGame中需要调用一个StartMoving()方法。通过这一篇的实践我们不仅完成了贪吃蛇的核心游戏循环更重要的是我们实践了如何在Unity中组织一个状态清晰、模块解耦的小型项目。从全局状态管理、事件通信到UI交互这些模式在几乎任何游戏项目中都是相通的。下次我们可以聊聊如何为贪吃蛇添加更多趣味性比如不同类型的食物加速、减速、穿墙、障碍物、或者简单的AI敌人让这个经典游戏焕发新的活力。