ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用Unity搭建贪吃蛇基础框架:核心原理、数据结构与工程实践

用Unity搭建贪吃蛇基础框架:核心原理、数据结构与工程实践 提起贪吃蛇很多人觉得它是编程入门的“Hello World”但如果你用Unity认认真真把它做成一个可扩展的基础框架会发现里面值得拆解的细节还真不少。咱们先抛一个观点贪吃蛇的真正难点从来不是“蛇怎么动”而是“逻辑层怎么和表现层解耦”。本文不打算只贴一段能跑的代码而是把从零搭建这个基础框架时的思路、数据结构选型、场景搭建细节、以及实际踩过的坑完整记录下来。适合刚开始学Unity、想在项目里建立“框架感”的朋友也适合想快速拿一个可复用模板做玩法改造的开发者。1. 内容整体设计与思路拆解1.1 为什么用Unity做“基础框架”而不是只做一个游戏如果你去搜一下会发现贪吃蛇的教程遍地都是大多数直接把所有逻辑写进一个脚本里Update里堆上一堆if。这种写法跑通很容易但等到你想加“暂停”“重开”“加速”“皮肤系统”“双人对战”的时候就会开始痛苦地改代码。用Unity做基础框架核心目标不是复刻一个能玩的贪吃蛇而是把通用游戏流程沉淀下来游戏状态机、输入处理、数据更新、碰撞反馈、UI刷新、重新开始。这些东西在任何小游戏里都能复用。贪吃蛇只是载体框架才是重点。我做这个项目时给自己定了三条约束代码层、表现层、控制层分离后续替换成其它玩法时不需要重写UI逻辑。所有可变参数网格尺寸、初始速度、加速系数集中配置方便调平衡性。必须支持“游戏结束—重新开始”的干净循环不重启场景也能重置所有状态。确定这三条之后整个项目结构就清爽了GameManager负责状态和主循环SnakeController负责蛇自身的数据与移动规则FoodSpawner负责生成食物UIManager负责分数和重新开始按钮。各管各的互相只通过公开方法和事件通信。1.2 对比JavaScript版本Unity的天然优势在哪你如果看过经典“升级版贪吃蛇(htmlcssjavascript)”会发现纯前端做贪吃蛇也能做出不错的效果重新开始按钮、分数越高速度越快这些功能都有。但用Unity做有几个天然优势是JS版本很难替代的。第一场景编辑能力。网格背景、蛇身格子、食物图标都可以用Prefab和Tilemap可视化摆放不需要在代码里硬编码坐标映射。第二碰撞检测不用手写。JS版本要自己判断“蛇头坐标是否等于食物坐标”Unity里用Trigger或网格坐标对比都行而且坐标对比还是我们主要用的方案。第三跨平台发布。同一个工程可以输出到PC、移动端、微信小游戏等平台UI布局通过Canvas自动适配这套框架换平台几乎不用改逻辑代码。不过这里也得提醒一句Unity框架的优势在于扩展和迭代如果你的最终目标只是一个网页Demo那纯HTMLCSSJS反而更轻量。框架选型永远取决于你要走多远。2. 核心细节解析与实操要点2.1 网格坐标系为什么不用Transform本身当移动标准贪吃蛇最基础的问题蛇是怎么“移动”的。很多新手第一次会写成每一帧给蛇头加一个速度向量然后让身体跟着头走。这个做法在物理类游戏里很自然但在贪吃蛇里会有两个麻烦一是蛇的转向是90度直角浮点移动会导致和食物坐标对不齐二是身体跟着头走需要记录大量历史位置还要处理间距问题很繁琐。我的方案是整个游戏逻辑跑在一个整数网格坐标系上。用一个Vector2Int表示格子坐标例如(0, 0)是左下角(9, 9)是右上角。Unity场景里的世界坐标只是渲染层的事逻辑层完全不感知。这样做的好处非常直接判断蛇头是否吃到食物只要比较两个Vector2Int是否相等。判断蛇是否撞墙只要看蛇头坐标是否超出网格边界。判断蛇是否咬到自己只需要在蛇身坐标列表里查找是否有重复。转向是绝对精确的90度不会出现“差一点没吃到”的尴尬情况。有人说Unity场景里用什么单位我用的是1个格子 1 Unity单位场景中每个蛇身节点就是一个SpriteRenderer位置等于(coordinate.x 0.5f, coordinate.y 0.5f, 0)。加0.5是为了让格子中心对齐到网格交点之间这不是必须的只是为了让渲染更居中好看。2.2 蛇的数据结构和移动算法蛇身数据我用的是ListVector2Int索引0表示蛇头。为什么不用QueueVector2Int因为虽然移动时头部添加、尾部移除很像队列操作但后续如果要实现“吃到食物后只在尾部不删”“身体长度动画渐变”“双人对战中的反向碰撞”等功能List更灵活而且List可以随机访问任意索引方便调试。每次“移动”的逻辑可以抽象成两步// 假设direction是当前移动方向 Vector2Int newHead snake[0] direction; // 如果这一帧吃到了食物只添加头部不删除尾部蛇长度1 if (eatFood) { snake.Insert(0, newHead); } // 如果没吃到食物添加头部删除尾部长度不变 else { snake.Insert(0, newHead); snake.RemoveAt(snake.Count - 1); }这看起来很简单但有一个容易被忽略的点先移动头部再删除尾部和先删除尾部再移动头部结果在大多数情况相同但在“蛇身长度为1”或“蛇头即将撞到旧尾部”时会有细微差异。我建议统一用“插入头部 → 判断碰撞 → 决定是否删除尾部”的顺序这样逻辑更直观碰撞判定永远基于最新的蛇身状态。碰撞检测也放在这个流程里在插入新头部之后立刻检查// 边界碰撞 if (newHead.x 0 || newHead.x gridWidth || newHead.y 0 || newHead.y gridHeight) { GameOver(); return; } // 自身碰撞从索引1开始查因为索引0就是蛇头自己 for (int i 1; i snake.Count; i) { if (snake[i] newHead) { GameOver(); return; } }这里有个小坑如果蛇的长度是4并且下一步移动方向是反方向蛇头会直接撞到脖子。所以输入系统必须提前拦截反向操作不能让非法方向进入移动逻辑。2.3 方向输入处理禁止反向和输入缓存吃过的朋友都知道贪吃蛇不能瞬间掉头。系统的输入处理关键在于两步忽略反向、缓存连续输入。很多教程写的是“直接判断输入方向与当前方向是否为反向是则忽略”。但这在快速操作时会出现体验问题假设蛇正在向右移动玩家在一帧内依次按下“上”和“左”如果每次输入都立即生效先转向向上没问题但下一帧玩家可能期望的是向左实际上游戏已经转向上了因为这个输入被这一帧的Update消费掉了或者被反向判断忽略掉了。解决方法是引入一个输入队列称为directionBufferprivate QueueVector2Int directionBuffer new QueueVector2Int(); void Update() { Vector2Int inputDir GetInputDirection(); if (inputDir ! Vector2Int.zero) { // 只入队不直接生效 directionBuffer.Enqueue(inputDir); } }每次移动的Tick到来时从队列头部取出有效的方向并且要保证新方向和当前方向不构成反向Vector2Int effectiveDir currentDirection; while (directionBuffer.Count 0) { Vector2Int candidate directionBuffer.Dequeue(); if (candidate -currentDirection) continue; // 反向丢弃 effectiveDir candidate; break; } currentDirection effectiveDir;这样在一帧内按“上”再按“左”蛇会先向上走一步然后在下一步尝试向左不会出现反向自杀或者输入丢失的问题。这个细节可以说是我见过最多的“基础框架变高级框架”的分水岭。2.4 食物生成随机避让蛇身食物生成算法可以直接用随机数但要排除蛇身占据的格子。简单办法是在网格内随机生成一个坐标若与蛇身重合则重新随机如果试了很多次仍重合说明蛇几乎占满了整个网格此时应该直接判定胜利。public Vector2Int GenerateFoodPosition() { int maxAttempts gridWidth * gridHeight; for (int i 0; i maxAttempts; i) { Vector2Int candidate new Vector2Int( Random.Range(0, gridWidth), Random.Range(0, gridHeight) ); if (!snakeController.SnakeContains(candidate)) return candidate; } return new Vector2Int(-1, -1); // 表示无空格应触发胜利 }这个方式在网格比较小比如10×10时效率完全没问题。如果以后要支持超大地图可以用HashSetVector2Int维护已占用的格子随机时直接在一个可用列表里取但前期真没必要过度优化。2.5 速度递增分数越高速度越快这个需求在热词里反复出现也是贪吃蛇“上瘾感”的主要来源。实现思路不是直接减少每帧的等待时间而是把基础移动间隔和加速梯度分别配参数。我用的公式是每步间隔 初始间隔 - 当前分数 * 每分减少间隔并且限制一个最小间隔防止后面快到完全没法玩。参数在配置脚本里统一暴露[System.Serializable] public class SpeedConfig { public float startInterval 0.3f; // 初始每步间隔秒 public float minInterval 0.08f; // 最快间隔 public float intervalReducePerScore 0.002f; // 每得1分减少0.002秒 }在GameManager里用协程或累积时间计算移动时机。我为了让大家更容易理解用的是Update里累积计时的方式private float timer; void Update() { if (GameState ! GameState.Playing) return; timer Time.deltaTime; float currentInterval Mathf.Max( speedConfig.minInterval, speedConfig.startInterval - score * speedConfig.intervalReducePerScore ); while (timer currentInterval) { timer - currentInterval; StepSnake(); // 执行一步移动 } }这里用while而不是if是为了防止某一帧卡顿导致错过多个移动步尽可能保持游戏节奏。不过要注意while循环次数要限制避免帧率极低时一次性迭代几百次导致卡住可以加一个最大步数保护。实际项目中我设了最多每帧执行5步超过5步就丢弃剩余时间保证性能平稳。3. 实操过程与核心环节实现3.1 场景搭建与脚本结构我用的Unity版本是2021.3 LTS2D URP模板。新建工程后场景结构如下GameManager空物体挂GameController.cs负责状态控制和主循环。SnakeRoot空物体挂SnakeController.cs负责蛇身列表和移动逻辑。FoodSpawner空物体挂FoodSpawner.cs负责生成食物。Canvas/UI包含一个TextMeshPro文本显示分数一个Button作为重新开始按钮。Main Camera挂CameraFollow.cs让摄像机跟随蛇头移动。这套结构里GameManager像是一个“导演”它不关心蛇身怎么渲染只负责在正确时机调用SnakeController.MoveNext()然后询问是否吃到食物、是否游戏结束。SnakeController也不关心UI只在自身状态变化时触发事件。3.2 GameManager完整实现先放核心代码再逐个解释。这个版本是精简但完整可运行的using System; using System.Collections; using UnityEngine; using UnityEngine.UI; public enum GameState { Ready, Playing, GameOver } public class GameController : MonoBehaviour { [Header(Grid Settings)] public int gridWidth 15; public int gridHeight 15; [Header(Speed Settings)] public float startInterval 0.25f; public float minInterval 0.08f; public float intervalReducePerScore 0.002f; [Header(References)] public SnakeController snake; public FoodSpawner foodSpawner; public TextMeshProUGUI scoreText; public Button restartButton; public CameraFollow cameraFollow; public GameState State { get; private set; } public int Score { get; private set; } private float timer; private int maxStepsPerFrame 5; void Start() { restartButton.onClick.AddListener(RestartGame); StartNewGame(); } void Update() { if (State ! GameState.Playing) return; timer Time.deltaTime; float currentInterval Mathf.Max( minInterval, startInterval - Score * intervalReducePerScore ); int stepCount 0; while (timer currentInterval stepCount maxStepsPerFrame) { timer - currentInterval; stepCount; StepGame(); if (State ! GameState.Playing) break; } if (stepCount maxStepsPerFrame) timer 0f; } private void StepGame() { snake.MoveNext(); if (snake.CheckSelfCollision() || !snake.IsInsideGrid(gridWidth, gridHeight)) { GameOver(); return; } if (snake.HeadPosition foodSpawner.CurrentFoodPosition) { snake.Grow(); Score 1; UpdateScoreUI(); foodSpawner.Spawn(snake); } } public void StartNewGame() { State GameState.Playing; Score 0; timer 0f; snake.ResetSnake(); foodSpawner.Spawn(snake); UpdateScoreUI(); restartButton.gameObject.SetActive(false); } private void GameOver() { State GameState.GameOver; restartButton.gameObject.SetActive(true); // 这里可以额外播放音效、动画等 } private void UpdateScoreUI() { scoreText.text 得分 Score; } public void RestartGame() { StartNewGame(); } }有几个细节我想强调一下。StartNewGame里我没有使用SceneManager.LoadScene来重启而是在同场景中重置所有对象状态。这比重新加载场景快很多而且能够保留场景中其它UI元素的状态。snake.ResetSnake()和foodSpawner.Spawn(snake)这两个调用顺序很重要先重置蛇再生成食物否则食物可能生成在蛇身上。maxStepsPerFrame的限定我在实际运行时遇到过极端情况把游戏切到后台很久回到前台时Time.deltaTime会变得很大如果不加限制while循环会一次性执行几十步游戏直接判定死亡。限制之后回到前台最多只连续走5步体验好很多。3.3 SnakeController实现移动、渲染、重置SnakeController是蛇的核心逻辑。先用ListVector2Int保存逻辑坐标然后维护一个GameObject列表作为蛇身节点。using System.Collections.Generic; using UnityEngine; public class SnakeController : MonoBehaviour { public GameObject snakeNodePrefab; public Sprite headSprite; public Sprite bodySprite; private ListVector2Int snake new ListVector2Int(); private ListGameObject nodeObjects new ListGameObject(); private Vector2Int currentNodeDirection Vector2Int.right; private QueueVector2Int directionBuffer new QueueVector2Int(); public Vector2Int HeadPosition snake[0]; public int Length snake.Count; void Update() { Vector2Int input GetInputDirection(); if (input ! Vector2Int.zero) { directionBuffer.Enqueue(input); } } public void MoveNext() { while (directionBuffer.Count 0) { Vector2Int candidate directionBuffer.Dequeue(); if (candidate -currentNodeDirection) continue; currentNodeDirection candidate; break; } Vector2Int newHead snake[0] currentNodeDirection; snake.Insert(0, newHead); snake.RemoveAt(snake.Count - 1); RefreshNodeVisuals(); } public void Grow() { Vector2Int tail snake[snake.Count - 1]; // 让蛇变长攻击者关心的是下一次MoveNext不删除尾部。 // 但这里为了保持逻辑一致我们直接补一个节点然后设置一个“待增长”标记。 // 一种简单方式贪吃蛇吃到食物,长度1我在这里是复制尾部位置 snake.Add(tail); RefreshNodeVisuals(); } public bool CheckSelfCollision() { for (int i 1; i snake.Count; i) { if (snake[i] snake[0]) return true; } return false; } public bool IsInsideGrid(int width, int height) { return snake[0].x 0 snake[0].x width snake[0].y 0 snake[0].y height; } public bool SnakeContains(Vector2Int pos) { return snake.Contains(pos); } public void ResetSnake() { snake.Clear(); snake.Add(new Vector2Int(7, 7)); snake.Add(new Vector2Int(6, 7)); snake.Add(new Vector2Int(5, 7)); currentNodeDirection Vector2Int.right; directionBuffer.Clear(); RefreshNodeVisuals(); } private void RefreshNodeVisuals() { while (nodeObjects.Count snake.Count) { GameObject go Instantiate(snakeNodePrefab, transform); nodeObjects.Add(go); } for (int i snake.Count; i nodeObjects.Count; i) { nodeObjects[i].SetActive(false); } for (int i 0; i snake.Count; i) { GameObject node nodeObjects[i]; node.SetActive(true); node.transform.position new Vector3( snake[i].x 0.5f, snake[i].y 0.5f, 0f ); SpriteRenderer sr node.GetComponentSpriteRenderer(); if (sr ! null) { if (i 0) sr.sprite headSprite; else sr.sprite bodySprite; } } } private Vector2Int GetInputDirection() { if (Input.GetKeyDown(KeyCode.W) || Input.GetKeyDown(KeyCode.UpArrow)) return Vector2Int.up; if (Input.GetKeyDown(KeyCode.S) || Input.GetKeyDown(KeyCode.DownArrow)) return Vector2Int.down; if (Input.GetKeyDown(KeyCode.A) || Input.GetKeyDown(KeyCode.LeftArrow)) return Vector2Int.left; if (Input.GetKeyDown(KeyCode.D) || Input.GetKeyDown(KeyCode.RightArrow)) return Vector2Int.right; return Vector2Int.zero; } }这个版本最需要注意的地方是Grow()的实现。我上面代码里写的是“复制尾部坐标再添加一个节点”这在蛇没有移动的时候看起来很奇怪蛇身长度变长了但最后一个节点和倒数第二个节点坐标完全一样渲染时两个节点会重叠。但这个位置的坐标会在下一次MoveNext()时被“推”出来吗并不会因为我的MoveNext()是插入头部并删除尾部尾部删除的是snake[snake.Count - 1]而这个重复的尾部会被删除其它节点位置不变所以蛇的长度并没有真实增加。这个bug是我故意要重点说明的。正确做法是引入一个pendingGrowth标记在下次移动时不删除尾部。我推荐下面这个写法private bool pendingGrowth; public void MoveNext() { // ... 处理方向 ... Vector2Int newHead snake[0] currentNodeDirection; snake.Insert(0, newHead); if (pendingGrowth) { pendingGrowth false; } else { snake.RemoveAt(snake.Count - 1); } RefreshNodeVisuals(); } public void Grow() { pendingGrowth true; }这样吃掉食物后下一帧移动时蛇会真正“长”出一格视觉上也非常符合直觉。不要小看这个问题很多从JS移植过来的教程都会在这里翻车。3.4 FoodSpawner和UI绑定FoodSpawner很简单维护一个当前食物的坐标和对应GameObject。using UnityEngine; public class FoodSpawner : MonoBehaviour { public GameObject foodPrefab; public Vector2Int CurrentFoodPosition { get; private set; } private GameObject foodInstance; public void Spawn(SnakeController snake) { int maxAttempts 15 * 15; // 示例实际可以从GameController读取 Vector2Int candidate Vector2Int.zero; for (int i 0; i maxAttempts; i) { candidate new Vector2Int( Random.Range(0, 15), Random.Range(0, 15) ); if (!snake.SnakeContains(candidate)) break; } if (foodInstance null) foodInstance Instantiate(foodPrefab, transform); foodInstance.transform.position new Vector3( candidate.x 0.5f, candidate.y 0.5f, 0f ); CurrentFoodPosition candidate; } }注意这里有硬编码的网格尺寸更好的方式是让GameController把gridWidth和gridHeight传进来。我在实际框架里就是用参数传递的避免以后改网格导致食物生成越界。这里为了示例清晰我没有写全但你在自己工程里一定要加上。UI绑定部分我在Canvas下创建了一个TextMeshProUGUI显示分数一个Button作为重新开始按钮。按钮默认隐藏游戏结束才显示。这个交互逻辑很简单但有一点值得提使用TextMeshPro而不是传统Text因为TMP的字体清晰度更好而且在Unity新版本里TextLegacy已经逐渐边缘化新项目没必要再用老UI组件。3.5 摄像机跟随的细节处理热词里有“unity摄像机跟随”在贪吃蛇这个项目里可以做一个自适应跟随。不同于平台跳跃游戏里摄像机的固定偏移贪吃蛇的地图是有限的网格所以最省事的方案是让摄像机始终对准整个地图中心蛇活动范围不会离开地图。如果想让摄像机跟随蛇头但又不想让画面剧烈抖动可以做一个平滑跟随public class CameraFollow : MonoBehaviour { public Transform target; public float smoothTime 0.2f; private Vector3 velocity Vector3.zero; void LateUpdate() { if (target null) return; Vector3 targetPos new Vector3(target.position.x, target.position.y, transform.position.z); transform.position Vector3.SmoothDamp(transform.position, targetPos, ref velocity, smoothTime); } }注意摄像机Z轴要保持在-10这样2D场景才能正常渲染。平滑系数太小会显得晃太大则感觉反应迟钝0.2秒在我实测里比较舒服。但如果你想让整个地图始终可见建议直接固定摄像机在网格中心给一个合适的orthographicSize。4. 常见问题与排查技巧实录4.1 方向反跑为什么我的蛇吃了自己这是贪吃蛇最经典的问题。原因几乎都是输入直接修改了当前方向而没有判断反向。比如正向右移动时按下左方向蛇头直接走向脖子然后自我碰撞判定触发游戏结束。解决方案就是前面说的“输入缓冲 反向过滤”。要特别注意的是反向判断要基于currentDirection而不是基于上一次输入。如果玩家一帧内按了“上→左”而当前方向是右那么“上”是合法的“左”相对“上”也是合法的因为左是上的垂直方向如果错误地用“上一个有效输入”来判断就会把合法的“左”丢掉。我踩过这个坑用队列能天然规避。4.2 快速按键导致“跳向”问题假如蛇以0.1秒一步的速度移动而玩家在0.01秒内按了四次方向键理论上蛇应该沿着队列依次转向。如果不用缓冲队列连续的四次按键可能因为Input.GetKeyDown在同一个FixedUpdate或Update里的执行顺序问题而丢输入。处理方式就是我在SnakeController里维护directionBuffer每帧只入队在MoveNext时统一消费。这样即使移动频率很高输入也不会丢失只是会有一点“延迟转向”的感觉但因为有缓冲玩家不会觉得按键无效。4.3 死亡/暂停后残留输入游戏结束后如果SnakeController.Update还在接收输入并写入directionBuffer那么点“重新开始”后第一次移动就会立刻执行残留的方向导致蛇突然朝意想不到的方向移动。这是一个很隐蔽的bug。解决方案有两个一是在GameOver时清理directionBuffer二是在Update里判断状态。我建议两个都做因为Update的输入读取在GameController状态更新之外最好让SnakeController自己知道当前是否允许输入。public void SetInputEnabled(bool enabled) { inputEnabled enabled; if (!enabled) directionBuffer.Clear(); }然后在GameController.GameOver()里调用snake.SetInputEnabled(false)在StartNewGame()里调用snake.SetInputEnabled(true)。这样最稳。4.4 移动间隔公式的边界情况当分数很高时startInterval - score * intervalReducePerScore会变成负数而Mathf.Max会保证不小于minInterval所以流程没问题。但要注意另一种边界分数很高时timer累积方式如果还是“每帧减去当前间隔”在间隔很小的情况下可能一帧内要执行很多步这就是我加maxStepsPerFrame的原因。另一个常见问题是吃东西后速度变化不明显。如果intervalReducePerScore设得太小比如0.0001玩家可能要吃几十个食物才感觉到速度变化。建议初始间隔0.25秒、每分减少0.002秒吃20个分数时间隔变为0.21秒100分时变为0.05秒左右体感变化明显但又不至于瞬间失控。4.5 构建和发布时的“隐形问题”如果你的目标是发布到移动端或微信小游戏有几个点要提前注意。移动端触控输入要单独处理不能直接用Input.GetKeyDown可以接入Input.touches或直接用Unity的Input System触控绑定。微信小游戏打包时要注意.NET版本选择建议使用.NET Standard 2.1尽量避免System.IO.File之类的小游戏受限API。如果你用TextMeshPro在首次导入时要执行“Import TMP Essentials”否则打包后字体可能显示异常。这个坑很常见我刚做的小游戏也遇到过编辑器里正常打包成exe后文字变成方块就是缺少TMP资源。4.6 对象池基础框架要不要做回答这个问题得看场景。基础框架阶段不需要。贪吃蛇的蛇身节点最多几十个Instantiate一次就够了减少节点复用的复杂度。但如果你想做“蛇身带拖尾特效”“大量粒子”等扩展效果那可以考虑对象池。我最后定的方案是“无限生成蛇变短时SetActive(false)复用”这样蛇变长时最多创建到最大长度之后不再新增对象性能上完全够用。5. 从基础框架到升级版的扩展思路5.1 让“分数越高速度越快”更可控我在前面给出的速度公式已经能实现自动加速但实际调优时你可以把它升级为多段变速曲线。比如前50分每吃一个减0.004秒50-100分每吃一个减0.002秒100分之后每吃一个减0.001秒。这样可以避免后期速度增长过快导致玩家完全操作不过来。多段曲线在代码里实现很简单就是在Update中根据当前分数选择不同计算公式。我建议把曲线数据做成一个AnimationCurve在Inspector里可视化调整比写死if更好调试public AnimationCurve speedCurve; // x分数占比y间隔倍率这个方案可读性更强而且非程序员也能在编辑器里调手感。5.2 从单局循环到完整游戏流程现在的框架只有“游戏进行中”和“游戏结束”两个状态但要让产品更完整可以加入“开始界面”“暂停按钮”“设置面板”。状态机里增加Ready和Pause在GameController.Update中根据状态决定是否调用StepGame。暂停功能的实现很简单在Update开头判断State GameState.Pause时直接return。但要注意SnakeController.Update里的输入读取是否也要停如果不停玩家在暂停界面按方向键会进入directionBuffer恢复后蛇会跳转方向。所以暂停界面时也要调用SetInputEnabled(false)这个坑值得做笔记。5.3 渲染表现和移动端适配基础框架的蛇身是比较朴素的方块。升级时可以给蛇头、身体、食物都加上动画比如蛇头按朝向旋转、吃到食物时播放“逐渐消失”的粒子效果。场景中可以用简单shader做描边或发光效果这在地图边界和食物上尤其好看。我之前看到“unity脚本控制逐渐消失”这个热词其实在食物和蛇身消失时都可以用DOTween做透明度和缩放动画。给食物加一个“被吃掉后缩放为0”的效果整个游戏手感会提升一大截。注意动画不要挡住逻辑判定最好在逻辑先判定吃到再触发视觉动画。移动端适配方面除了触控输入UI布局也要适配不同分辨率。把分数和按钮放在Canvas的四个角用锚点而不是绝对坐标。摄像机跟随在竖屏和横屏的显示范围不同需要根据设计目标调整orthographicSize。5.4 多场景架构和更多玩法如果你的贪吃蛇项目后续要和“关卡”“教程”“排行”结合起来可以考虑用多场景架构一个Boot场景负责加载全局管理器一个Game场景承载游戏逻辑一个UI场景叠加HUD。用SceneManager.LoadSceneAsync配合LoadSceneMode.Additive做UI叠加这样理论上是更清晰的分工。不过我要说句实在话贪吃蛇这个体量用多场景略重单场景加Panel切换反而更顺手。框架设计不要为了复杂而复杂。最后再分享一个小技巧项目写完后我把所有可调参数收敛到一个GameConfigScriptableObject中网格大小、初始速度、加速曲线、食物生成间隔全都暴露在编辑器里。后面我给策划调平衡性时只需要在Inspector里拖数值完全不用碰代码。这个做法虽然只是“基础框架”但对后续扩展的帮助是实实在在的。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进