Unity课设躲避小游戏开发教程:对象池、碰撞检测与性能优化
发布时间:2026/10/6 8:15:37 作者:尧图编辑部 阅读量:1,286

简介一款基于 Unity 引擎开发的简单躲避障碍小游戏源码包定位为期末课程设计、新手入门练手与 K12 阶段编程教学参考。游戏中玩家操控一个小人左右移动、跳跃躲避从天空持续掉落的障碍物撞击后会扣减生命值规则非常简洁适合初学者直观理解角色控制、碰撞检测、生命值管理等核心逻辑。压缩包共 21147 个文件体积约 552.12MB工程内包含近 4000 个 C# 语言脚本负责移动、跳跃、障碍生成与碰撞判定等逻辑还有大量 PNG 角色与障碍素材、场景、预制体、材质、动画、音效文件并配有说明文档与配置文件构成一套可直接运行、自由修改的完整 Unity 项目同时附带打包好的可执行程序无需安装引擎也能直接体验成品。目前已有 961 人学习下载。下载后既可以打开完整工程逐模块阅读源码梳理从场景搭建到碰撞扣血的实现路径也可以直接运行成品测试手感再依照课程要求调整障碍速度、掉落频率、生命值等参数快速完成自己的课设作品。1. 课设级躲避小游戏为什么我劝你先做减法再写代码期末周还剩几天你兴冲冲开了一个“unity期末课设-简单躲避障碍小游戏”的标题结果发现网上搜来的免积分资源要么缺场景、要么脚本报错、要么打开就是紫红色材质连跑都跑不起来。我见过太多人把一半时间耗在下载、解压、找缺失资源上最后答辩前一天还在U3D里喊“刚才还能跑”。这类课设的真实诉求不是做一个3A大作而是把一个能演示、能讲解、能二次开发的躲避玩法完整走通一整套流程。我给你的建议很直白先做减法再谈效果。场景就一个玩家控制逻辑就一个障碍物生成用一个管理器碰撞用Trigger处理UI只做开始、结算两屏。这套组合拳足够覆盖Unity课设最常见的评分点游戏逻辑清晰、界面完整、可玩性可见、源码能自圆其说。下文就按我平时给新手把关的方案展开从零把工程搭出来并把最容易让人血压升高的几个坑提前排掉。这篇笔记适合刚学完C#语法的同学也适合想快速出demo的转行开发者。2. 从零搭出可玩原型对象池、碰撞检测与难度曲线2.1 玩法选型单跑道跑酷比迎面飞弹更适合课设躲避游戏最常见的两种形态一种是玩家固定、障碍物迎面飞来一种是玩家在一条路上前进、障碍物从远处朝玩家压过来。我建议选后者也就是单跑道跑酷玩家在一条水平通道内左右移动障碍物从Z轴深处生成并匀速向摄像机方向移动。这个形态的代码量最小玩家的目标一目了然答辩时也最容易用“左右躲避、越往后越快”一句话讲清楚玩法。场景搭建上你不需要美术资源。地面用Unity内置的Plane或Cube拉薄玩家用一个胶囊体Capsule代替障碍物用Cube或Cylinder再用一个平行光Directional Light照亮场景即可。关键是要把玩家坐标的某个轴向固定住比如Y轴固定只允许在X轴上左右移动这样能省掉一大堆物理上的奇怪表现。在层级结构上我习惯把场景里的对象按职责分组对象类型作用PlayerCapsule Rigidbody玩家控制主体GroundCube/Plane赛道地面ObstacleSpawner空物体脚本负责按节奏生成障碍物ObstaclePool空物体脚本对象池容器管理复用UIManagerCanvas开始/暂停/结束界面不要在一个物体上堆十几个脚本课设代码被老师打开看的时候一个脚本对应一个明确职责最好解释也最好改。物理引擎里玩家身上的Rigidbody必须手动冻结旋转和Z轴位移否则碰撞之后角色会乱转演示一次就翻车。2.2 玩家控制脚本让“左右移动”也有手感控制脚本是整个游戏里最容易写出Bug的地方。常见的错误写法是每一帧直接用transform.position new Vector3(...)不乘Time.deltaTime导致高帧率电脑上玩家速度快得离谱低帧率电脑上根本躲不开答辩现场直接社死。正确写法是用Input.GetAxis(Horizontal)读取左右方向输入再乘速度、乘Time.deltaTime最后用Mathf.Clamp把玩家限制在赛道范围内。完整代码如下using UnityEngine; public class PlayerController : MonoBehaviour { [Header(移动参数)] public float moveSpeed 8f; // 左右移动速度 public float minX -4.5f; // 左边界 public float maxX 4.5f; // 右边界 [Header(碰撞反馈)] public GameObject hitEffect; // 可选撞击特效 private Rigidbody rb; void Start() { rb GetComponentRigidbody(); // 锁定旋转和Z轴只留X轴位移 rb.constraints RigidbodyConstraints.FreezeRotation | RigidbodyConstraints.FreezePositionZ; } void Update() { #if UNITY_EDITOR || UNITY_STANDALONE float moveAxis Input.GetAxis(Horizontal); #else // WebGL或移动端可用键盘或触摸横轴 float moveAxis Input.GetAxis(Horizontal); #endif float targetX transform.position.x moveAxis * moveSpeed * Time.deltaTime; targetX Mathf.Clamp(targetX, minX, maxX); transform.position new Vector3(targetX, transform.position.y, transform.position.z); } private void OnTriggerEnter(Collider other) { if (other.CompareTag(Obstacle)) { hitEffect.SetActive(true); // 触发后通知UIManager显示结算界面 Time.timeScale 0f; // 暂停游戏注意恢复 } } }逻辑说明把控制放在Update里而不是FixedUpdate因为Input的读取频率在Update中更稳定如果你放到FixedUpdate会受到物理步长影响低刷新率显示器上会感觉迟滞。Mathf.Clamp把目标位置限制在跑道内避免玩家穿模到场景外。参数说明moveSpeed在8到10之间手感最舒服太大容易一按就冲到边界太小会让人觉得角色“粘手”。minX和maxX要根据地面宽度调整如果地面长10个单位、以原点为中心那么±4.5就合适如果跑道更宽改成±6甚至±7。代码里加了UNITY_EDITOR宏是为了同时适配PC和WebGL导出课设如果用WebGL演示键盘输入不会出问题。2.3 对象池与生成器别在Update里Instantiate每个新手最容易写出的“毁灭性”代码就是每间隔0.5秒Instantiate一个障碍物、碰撞后再Destroy。这在刚运行时没问题玩两分钟就会发现帧率骤降GC报错一条接一条。原因很简单Instantiate和Destroy在内存上反复分配和回收触发大量托管堆碎片Unity的垃圾回收只要一憋不住就会卡顿。对象池的思路是开局一次性生成10到20个障碍物放进容器里不激活需要用的时候从容器里取一个用完后“禁用”并还回去而不是Destroy。这样整个游戏过程不会有物体创建和销毁。实现代码如下using System.Collections.Generic; using UnityEngine; public class ObstaclePool : MonoBehaviour { public GameObject obstaclePrefab; // 障碍物预制体 public int poolSize 15; // 池子大小 private readonly ListGameObject pool new ListGameObject(); void Start() { for (int i 0; i poolSize; i) { GameObject obj Instantiate(obstaclePrefab); obj.SetActive(false); pool.Add(obj); } } // 从池中取一个可用的对象 public GameObject Get() { foreach (GameObject obj in pool) { if (!obj.activeInHierarchy) { obj.SetActive(true); return obj; } } // 池不够用就扩容一个 GameObject extra Instantiate(obstaclePrefab); pool.Add(extra); return extra; } }生成器脚本用协程控制生成间隔比Update里倒计时更直观using System.Collections; using UnityEngine; public class ObstacleSpawner : MonoBehaviour { public ObstaclePool pool; public float startInterval 1.5f; // 开局生成间隔 public float minInterval 0.5f; // 极限间隔 public float obstacleSpeed 6f; // 障碍物移动速度 void Start() { StartCoroutine(SpawnLoop()); } IEnumerator SpawnLoop() { while (true) { GameObject obstacle pool.Get(); obstacle.transform.position RandomXPosition(); // 让障碍物沿Z轴向玩家移动 obstacle.GetComponentMover().enabled true; float interval Mathf.Max(minInterval, startInterval - Time.time / 30f); yield return new WaitForSeconds(interval); } } Vector3 RandomXPosition() { float x Random.Range(-4f, 4f); return new Vector3(x, 1f, 20f); } }逻辑说明协程SpawnLoop借助WaitForSeconds替代Update倒计时代码更简洁也便于调整间隔。障碍物的移动我没有在上面写进Mover脚本而建议单独写一个两行的组件后面第3章会解释为什么这么做。难度曲线参数startInterval和minInterval决定了从“轻松躲”到“手忙脚乱”的节奏。我的经验是开局间隔1.5秒玩家有充足时间熟悉操作每过30秒间隔减少0.5秒直到下限0.5秒。obstacleSpeed固定为6如果同时提高速度和缩短间隔新手会直接崩盘根本体验不到“渐进难度”的快感。2.4 碰撞检测与计分用Trigger还是Collider很多课设喜欢在OnCollisionEnter里做判断结果玩家撞到障碍物后被物理引擎弹飞十万八千里。原因是你用了非运动学刚体碰撞物理引擎会在碰撞瞬间算出反弹力。处理游戏逻辑的正解是用触发器Trigger把障碍物的Collider设为isTrigger玩家身上的Rigidbody设为动态但关闭重力碰撞时走OnTriggerEnter只触发事件不产生物理弹跳。障碍物上的Mover脚本是这个方案的一部分using UnityEngine; public class Mover : MonoBehaviour { public float speed 6f; void Update() { transform.Translate( Vector3.back * speed * Time.deltaTime); // 超出边界就回收而不是销毁 if (transform.position.z -3f) { gameObject.SetActive(false); } } }计分脚本挂在玩家身上或者单独挂在UI管理器上。最简单的计分方式是“存活时间换算”游戏开始时计时被撞后读取当前时间作为最终得分。这种方式比“每过一帧加一分”更稳定using UnityEngine; using UnityEngine.UI; public class ScoreManager : MonoBehaviour { public Text scoreText; public TimerDisplay playerStatus; // 假设玩家身上挂了一个存活标记 private float startTime; private bool isOver; void Start() { startTime Time.time; isOver false; } void Update() { if (isOver) return; float score Time.time - startTime; if (scoreText ! null) { scoreText.text 存活时间: score.ToString(F1) s; } } public void StopAndShowFinal() { isOver true; // 把最终分数显示到结算界面并调用UIManager } }逻辑说明Mover里用gameObject.SetActive(false)实现“回收”这和对象池的Get()逻辑正好配对。注意不能在Mover里Destroy(gameObject)否则对象池里就少了一个可用对象下次Get()时会重新创建内存问题复萌。计分脚本里Time.time只在游戏未结束时累加死亡时由UI脚本读取最终值即可。3. Unity课设避坑5个让人血压升高的常见问题与排查顺序3.1 现象玩家撞到障碍物后穿过、抖动或直接被弹飞原因很统一你用了静态碰撞而不是触发器。玩家Rigiditybody在受碰撞时物理引擎严格按照碰撞形状接触算力和冲量如果速度过高还可能发生隧穿——物体一帧内穿过整个障碍物看起来就像穿了墙。解决分三步。第一步把玩家身上的Rigidbody设成Constraints锁定旋转和Z轴并关闭Use Gravity第二步把障碍物的Collider勾上Is Trigger第三步把玩家控制代码从OnCollisionEnter换成OnTriggerEnter。前两步做完物理弹跳现象立即消失第三步是让游戏逻辑稳定触发。不要为了省事用transform.position瞬移躲碰撞那是另一个坑。3.2 现象玩一会儿帧率骤降Memory水涨船高先查看Console有没有大量GC Alloc警告十有八九是障碍物生成脚本在Instantiate和Destroy之间反复横跳。我在第2章给的对象池就是标准解药。另一类是粒子特效的内存泄露粒子系统不断Play且没有设置StopAction或使用了Automatic Culling但粒子数量巨大每个粒子都在维护独立引用。排查顺序是先把障碍物脚本改成对象池再看性能窗口的Memory Profiler里能不能找到频繁新建的GameObject如果仍然上涨检查特效的Emission模块是否设置了循环大数值发射在游戏结束时对特效对象执行ParticleSystem.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear)。3.3 现象预制体改不动Inspector的引用是空很多免积分下载的工程打开后Spawner的obstaclePrefab字段是空的运行后生成器什么都不生成。原因是作者把预制体拖进了场景某个隐藏节点但没拖到Inspector暴露的槽位里或者预制体被移走导致引用失效。解决步骤在Project窗口里选中障碍物原始预制体左侧Inspector里选择“Open Prefab”检查它是否带Collider和Mover脚本然后把它拖到场景中Spawner物体的ObstacleSpawner.pool.obstaclePrefab槽位。如果你重新拖进去后还报空引用把旧预制体改名再拖一次Unity重新序列化一次基本能好。不要直接在场景里用Instantiate(障碍物实例)那会丢失池的复用能力。3.4 现象UI按钮点击没反应画布忽大忽小按钮点击失效的绝大多数原因是Canvas下有一层透明Image挡在按钮上方并勾选了Raycast Target。我常在Button上层放一个全屏的结束面板做完后忘关Image的Raycast Target结果整个游戏内UI按钮全部被“吃掉”。解决方法是选中全屏背景Image取消Inspector里Image组件上的Raycast Target复选框。而“画布忽大忽小”多半是Canvas Scaler设置错误把UI Scale Mode设为Constant Pixel Size在不同分辨率下UI物理尺寸就不一致应改为Scale With Screen Size参考分辨率设1280x720Match滑到0.5这样Unity会按宽高比例缩放UI。3.5 现象工程文件打不开或模型材质变成紫红色如果你下载的工程是Unity 2018创建的你用Unity 6直接打开大概率会升级失败或者脚本报错。对策是装一个与工程版本兼容的Editor把工程先升级到2020或2022然后再打开。紫红色材质更明显——Shader丢失或当前渲染管线不支持如果你用的是Built-in Render Pipeline而预制体用了URP/HDRP的Shader丢掉默认引用就会出现紫红色。解决方式是在Project窗口里找到紫红色材质把Shader改为Standard或Legacy Shaders/Diffuse。如果找不到原Shader直接新建材质把对应贴图拖入Albedo贴图槽内即可。这个现象在从网上下载的课设工程里极其常见也是我判定“资源有没有被精简过”的一个信号。4. 把原型变成能提交的成品UI、打包与包体优化4.1 用UGUI搭开始与结算界面一个Canvas搞定课设的UI不需要花哨但必须完整。我通常只做一个Canvas下面挂三个面板StartPanel、HUD、GameOverPanel。开始界面放游戏标题、操作提示和“开始游戏”ButtonHUD放存活时间Text结束界面放本局得分、重新开始和退出Button。Canvas设置要统一Render Mode选Screen Space - Overlay加上CanvasScaler按第3.4节的方式设置。绑定按钮用代码而不是Inspector拖拽更好维护using UnityEngine; using UnityEngine.UI; public class UIManager : MonoBehaviour { public GameObject startPanel; public GameObject hudPanel; public GameObject gameOverPanel; public Text finalScoreText; void Start() { // 初始状态只显示开始面板 startPanel.SetActive(true); hudPanel.SetActive(false); gameOverPanel.SetActive(false); // 按钮监听 startPanel.GetComponentInChildrenButton() .onClick.AddListener(StartGame); gameOverPanel.transform.Find(ReplayButton) .GetComponentButton().onClick.AddListener(RestartGame); gameOverPanel.transform.Find(QuitButton) .GetComponentButton().onClick.AddListener(QuitGame); } void StartGame() { startPanel.SetActive(false); hudPanel.SetActive(true); Time.timeScale 1f; } public void ShowGameOver(float score) { hudPanel.SetActive(false); gameOverPanel.SetActive(true); finalScoreText.text 存活时间: score.ToString(F1) s; Time.timeScale 0f; } void RestartGame() { Time.timeScale 1f; // 用SceneManager重载当前场景这是最省事的“重开”方案 UnityEngine.SceneManagement.SceneManager.LoadScene( UnityEngine.SceneManagement.SceneManager.GetActiveScene().name); } void QuitGame() { #if UNITY_STANDALONE Application.Quit(); #endif } }逻辑说明用Time.timeScale 0f暂停游戏后UI必须仍能响应点击因为UGUI的Update不受timeScale影响。重开游戏我用LoadScene重载整个场景这是最可靠的重置方式不用手动清理敌人列表、重置对象池、清分数。按钮监听用代码注册比Inspector拖事件更不容易出“找不到引用”的错。4.2 摄像机跟随与画面稳定锁定Z轴的跟随与边界限制多数新手把摄像机绑成玩家子物体结果玩家左右移动时摄像机也在左右晃看久了头晕。正确做法是让摄像机只跟随玩家的X轴或只跟随Z轴上的固定视角。我一般采用“半跟随”摄像机在Z轴上固定在X轴上以一定平滑速度跟随玩家。using UnityEngine; public class CameraFollow : MonoBehaviour { public Transform player; public float followSpeed 5f; // 水平跟随速度 public Vector3 offset new Vector3(0f, 8f, -8f); void LateUpdate() { if (player null) return; // 目标位置X取玩家XY/Z用固定值 Vector3 target player.position offset; // 用Lerp做平滑跟随 transform.position Vector3.Lerp( transform.position, target, followSpeed * Time.deltaTime); transform.LookAt(player); } }参数说明offset里的Y是摄像机高度8到10之间视角比较舒服Z是纵深距离-8会让玩家看起来在屏幕下方。followSpeed是摄像机横向跟随的响应速度5左右比较顺滑数值过高会“甩镜头”数值过低玩家横向移动时摄像机拖影明显。LateUpdate保证在玩家移动完毕后再更新摄像机避免视觉抖动。如果跑道很宽别忘了把摄像机的正交或透视视口调到能看见整条跑道否则玩家会走出屏幕。4.3 打包设置与分辨率适配从Unity到可执行文件答辩前打包这一步最容易翻车。常见的错是直接把Unity默认的Build Settings检查一遍就点击Build结果打出来的包巨大或者没选对平台。打包前按顺序确认五件事。第一用File - Build Settings切换到对应目标平台PC选PC, Mac Linux StandaloneWeb演示选WebGL再点Switch Platform第二在Player Settings - Product Name里把应用名改成自己项目名别留着“DefaultCompany”这种名字第三把Scripting Backend从Mono2x换成IL2CPP能减小包体并提升性能但首次编译时间会变长第四Resolution and Presentation里把默认分辨率设成1280x720并把Resizable Window勾上避免演示时窗口变形第五把场景拖进Scenes In Build漏掉场景是“打包后黑屏/只显示空场景”的头号原因。包体优化方面对课设来说最有用的是这几项关闭Player Settings - Other Settings里的Strip Engine Code选适合选项去掉没用到的Android/iOS模块把贴图压缩格式统一成ASTC或DXT5如果用WebGL压缩格式用Gzip。我做过最简单的2D躲避类游戏按这套设置可以从近100MB压到30MB出头完全够课设展示。注意粒子特效贴图和背景图不要用未经压缩的PNG大图那是包体膨胀的大头。4.4 WebGL与PC分发演示不崩才是硬道理课设答辩环境不可控建议你同时导出两个版本一个PC可执行文件一个WebGL版。PC版用File - Build Settings直接导出WebGL版需要先确认已经安装了WebGL Build Support模块——很多机器第一次勾选WebGL平台时会提示Module not installed需要去Unity Hub里补装这一步非常耗时提前做准备。WebGL版打包完成后Unity会生成一个Build文件夹。不要在本地双击index.html看效果那会因为浏览器安全策略限制导致读取失败正确的做法是在本地起一个HTTP服务或者用Unity WebGL Build自带的Server脚本在builds/目录下通常有serve.py。演示时建议用Chrome或Edge且有条件的话先用低性能笔记本试一次确认帧率能保持稳定。WebGL版的性能比PC版弱一截粒子特效和动态阴影建议在WebGL构建中关闭或降级。5. 一小时加上的进阶亮点音效、粒子特效与难度验证5.1 撞击反馈粒子和音效的“低成本注入”一套简单的撞击特效能在答辩中给你挣回不少印象分。做法是给玩家加一个子物体下的ParticleSystem预设为一个小型爆发效果。关键在于粒子的生命周期管理如果你不主动停止粒子系统会一直申请内存并持续模拟久了就出现上一章说的“粒子特效内存泄露”。正确写法是在撞击时只触发一次using UnityEngine; public class HitEffect : MonoBehaviour { public ParticleSystem explosion; public void PlayOnce() { // 先清空已有粒子再播放一次 explosion.Clear(); explosion.Play(); } }Clear()与Stop()组合是粒子系统的后悔药Clear清掉残留粒子Play再从第一帧开始。调用时用Invoke(DelayStop, 1f)在1秒后执行explosion.Stop(true)防止粒子循环播放。配上音效只需要在玩家对象上挂一个AudioSource把撞击时的clip拖进去在PlayOnce()里同时audioSource.PlayOneShot(clip);。不用做混音器也不用做复杂音效管理两行代码就够。5.2 用摄像机震动和HitStop让撞击“有感觉”如果还有半小时富余再给撞击加一个非常便宜的反馈HitStop。即撞击瞬间暂停一小段时间让玩家看到碰撞结果再恢复。实现方式是撞击时调用Time.timeScale 0.05f然后用协程在0.08秒后恢复为1。注意你同时还要管理UI里的重新开始按钮因此要在恢复后再允许点击或者直接在协程里让UIManager弹出结算。摄像机震动也一样容易把CameraFollow的LateUpdate里加一个偏移量当shakeTime 0时用随机偏移叠加到target上。public float shakeTime 0f; public float shakeStrength 0.4f; void LateUpdate() { ... if (shakeTime 0f) { shakeTime - Time.deltaTime; Vector3 shakeOffset Random.insideUnitSphere * shakeStrength; transform.position Vector3.Lerp(transform.position, target shakeOffset, followSpeed * Time.deltaTime); } ... }这个技巧最常用于小游戏的打击感和撞击反馈参数shakeStrength我一般在0.3到0.5之间太大会让镜头眩晕太小则无感。配合粒子和音效这一套组合足够让老师在答辩时觉得你的游戏“有细节”。5.3 验证难度曲线什么才叫“刚好能玩”比加特效更重要的是验证你的难度曲线是否让人能玩得下去。我见过无数课设败在最后一点上开局就是地狱难度或者打到两分钟都毫无压力。前者的原因是obstacleSpeed和startInterval不匹配后者的原因是障碍物始终只有单车道玩家只要原地不动就能躲开。验证方法很简单自己以“不操作”的方式挂机30秒看角色多久会被撞。如果30秒内必死说明障碍物密度已经过高如果挂机一分钟仍无伤说明难度曲线太温和。具体参数组合可参考这个表时间阶段生成间隔障碍物速度感受预期前10秒1.5秒6轻松、能看清轨迹10–30秒1.2秒7需要主动移动30–60秒0.9秒8必须连续变道60秒以上0.6秒9手忙脚乱、极限操作每次调整后都让一个没玩过的人试玩一次宁可初期偏简单也不要一上来就劝退。课设最稳妥的设计是“前20秒让任何人能过后1分钟让熟练玩家感到挑战”这样老师上手时体验好后续有表演空间。最后的建议是交作业前把电脑分辨率切到1280x720按一次WebGL和一次PC包找一台不是开发机的电脑跑一遍把发布的exe拷到U盘里再打开一次。我吃过最大的教训是“在自己电脑上跑得飞快答辩机器上卡成PPT”——最后发现是打包时忘了关后台资源压缩。这类事一次就能避开。希望这篇笔记能帮你少熬一个通宵把你的第一个躲避小游戏稳稳跑起来。本文还有配套的精品资源点击获取