Unity课设全流程:躲避障碍小游戏从场景搭建到打包实战
发布时间:2026/10/6 8:15:37 作者:尧图编辑部 阅读量:1,286

简介一份用于Unity期末课程设计的简单躲避障碍小游戏项目适合需要完成课设或希望上手Unity游戏开发的初学者也可用于K12编程教学演示。游戏操控一个小人在平台上左右移动并跳跃躲避从天空不断掉落的障碍物被撞击后会扣除生命值整体机制简单清晰。压缩包共包含21147个文件大小约552.12MB涵盖C#游戏逻辑脚本、Unity场景与预制体、材质动画、PNG贴图及运行所需的DLL库并附可直接运行的exe程序便于快速体验与验证。源码结构完整清晰实现了角色移动跳跃、障碍物随机生成、碰撞扣血和UI生命值提示等核心逻辑可作为课设参考也可在此基础上扩展道具、难度与音效。目前已有961人学习或下载适合Unity入门者练习交流和期末项目参考。1. 一个Unity期末课设为什么值得把躲避障碍做“厚”期末课设选“简单躲避障碍小游戏”的人特别多因为它看起来是Unity里门槛最低的题目一个方块、一堆障碍、一个碰撞检测。但这个题目恰恰也是最容易在答辩现场被问穿的一种“会跑”和“讲得清楚”是两回事。我习惯把这类课设拆成场景搭建、刚体控制、障碍生成、碰撞判定、UI状态、打包交付六段来做每一段都有能直接复现的脚本和参数。这篇文章适合零基础想快速交差的学生也适合已经把Demo跑起来、但不知道怎么把难度曲线和状态机讲明白的人。标题里写着免积分下载其实课设中途最大的坑往往不在资源而在你把别人的工程下载下来却发现打不开、版本不对、材质紫红。自己照着往下搭一遍比下载一个zip更省时间也更对得起答辩那五分钟。2. 先把场景和玩家控制立起来Unity版本、物理与刚体设置2.1 选Unity版本和工程模板LTS优先别把时间赔在Shader上打开Unity Hub前先想好用它。常见做法是直接用LTS版本而不是最新预览版因为很多教材和视频还停留在Unity 2018入门与实战那套写法核心API并没有大变所以你不必纠结版本号但要保证小组成员都用同一个版本否则工程互相打开时材质、预制体、Lighting数据都会重建一遍。安装时在Unity Hub里勾好需要的模块Windows Build Support、Android Build Support如果只交PC版Windows模块就够。unity安装时登录账号有时会卡在授权环节多数是网络问题换个时段再试即可这个不用花太多时间。工程模板我建议选3D内置渲染管线不要为了“高级”选URP。简单躲避障碍用不到全局光照的后期表现URP反而会让默认Standard材质变成紫红色后面排查起来头疼。如果你已经建了URP工程可以用Window-Rendering-Render Pipeline Converter批量转换材质但课设只有几天没必要给自己加活。创建完工程后先把Player Settings里的Company Name、Product Name改掉别留着DefaultCompany这两个字段会写进打包后的exe属性里老师双击查看详情时能看到你的工程素养。2.2 玩家物体的最小构成Cube加Rigidbody为什么不用CharacterController场景里创建一个Cube缩放(1,1,1)放在(0,0.5,0)。给它添加Rigidbody。为什么优先Rigidbody而不是CharacterControllerCharacterController适合人形角色自带胶囊体的碰撞和坡度限制在躲避障碍课设里你只需要一个能被碰撞检测调用的物理物体。Rigidbody配合BoxCollider是Unity物理的基础组合碰撞回调、触发器都能用到。Rigidbody的Interpolate建议选Interpolate不然低帧率下移动会有卡顿感Constraints里把Y轴Position和Rotation全部冻结只留X和Z移动这样玩家不会被障碍物推倒也不会在碰撞后乱翻。控制脚本PlayerController.cs挂在玩家Cube上。代码using UnityEngine; public class PlayerController : MonoBehaviour { [Header(移动参数)] public float moveSpeed 5f; private Rigidbody rb; void Start() { rb GetComponentRigidbody(); // 锁住旋转防止碰撞后乱翻 rb.freezeRotation true; } void FixedUpdate() { float h Input.GetAxisRaw(Horizontal); float v Input.GetAxisRaw(Vertical); Vector3 direction new Vector3(h, 0f, v).normalized; // 直接赋速度手感线性不会像AddForce那样越滚越快 rb.velocity direction * moveSpeed; } }这段代码的逻辑是在FixedUpdate里读取输入轴把方向归一化后赋给Rigidbody的velocity。用GetAxisRaw而不是GetAxis是因为Raw轴没有平滑过渡课设需要的反应是“按下动、松开停”而不是键盘滑行。用velocity而不是AddForce也是同一理由加力会累积到下一次物理更新你很难向答辩老师解释“手感”是怎么调出来的。moveSpeed初值建议5如果觉得躲避太快就调到3但别低于2否则障碍物一多就来不及反应。还有一个参数很容易漏Rigidbody的Mass保持默认1Drag和Angular Drag都设成0。因为我们用velocity直接赋速度Drag不会影响结果但保留0可以让物理行为更可预期。注意移动逻辑必须写在FixedUpdate里不要写在Update。FixedUpdate按物理帧跑Update按渲染帧跑两者在不同帧率下的速度表现不一样这也是很多人换台电脑玩起来手感不同的原因。2.3 摄像机跟随从“跟随”到“不抖”的三种写法摄像机跟随有三种常见写法。第一种是把主摄像机拖成玩家的子物体位置设到玩家后上方一劳永逸但玩家每帧旋转或Rigidbody微抖动都会直接传给摄像机画面容易晃。第二种是在Update里执行transform.position target.position offset也能用但Update不保证在物理更新之后可能出现一帧拖尾。我一般用LateUpdate它在所有Update和动画更新之后调用能避开物理抖动。如果还想要镜头“柔”一点就用Vector3.SmoothDamp。挂一个CameraFollow.cs到主摄像机using UnityEngine; public class CameraFollow : MonoBehaviour { public Transform target; // 玩家Cube public Vector3 offset new Vector3(0f, 6f, -6f); public float smoothTime 0.3f; private Vector3 velocity Vector3.zero; void LateUpdate() { if (target null) return; Vector3 targetPos target.position offset; transform.position Vector3.SmoothDamp( transform.position, targetPos, ref velocity, smoothTime); transform.LookAt(target); } }这里和直接赋值位置的区别是SmoothDamp会给追赶过程一个阻尼速度smoothTime越大镜头越“懒”。我习惯0.3快速游戏中镜头能跟住也不觉得晕。如果玩家有纵向跳跃把offset的Y调高一点比如8避免障碍物挡视线。LookAt会让摄像机一直看向目标中心但如果玩家在躲避障碍时转头或者被碰撞带了一点旋转镜头也会跟着转容易晕。更稳的做法是锁定摄像机的欧拉角只让位置跟随例如把transform.LookAt(target)换成transform.rotation Quaternion.Euler(30f, 0f, 0f)这样镜头永远保持同一个俯视角度。两种方案都能用答辩时你能说清楚为什么选LateUpdate就够了。3. 障碍生成与碰撞判定把“会死”做到可解释3.1 障碍物的生成方式预制体随机间隔比一锅端更稳先定义方向摄像机在玩家后上方玩家面向Z障碍物从Z20往Z-方向移动。新建一个Cube改名为Obstacle加BoxCollider勾选IsTrigger再挂一个ObstacleMover.cs负责移动。代码很短using UnityEngine; public class ObstacleMover : MonoBehaviour { public float speed 4f; void Update() { // 朝向玩家方向移动速度由DifficultyManager统一改 transform.position Vector3.back * speed * Time.deltaTime; } }然后把Obstacle拖到Assets文件夹生成预制体再从场景删除原物体。在场景里创建一个空物体Spawner挂上ObstacleSpawner.cs。生成脚本用协程控制循环比一锅端生成要稳using UnityEngine; using System.Collections; public class ObstacleSpawner : MonoBehaviour { public GameObject obstaclePrefab; public Vector3 spawnPos new Vector3(0f, 0.5f, 20f); public float minInterval 1f; public float maxInterval 3f; public float fallSpeed 4f; void Start() { StartCoroutine(SpawnLoop()); } IEnumerator SpawnLoop() { while (true) { SpawnOne(); float wait Random.Range(minInterval, maxInterval); yield return new WaitForSeconds(wait); } } void SpawnOne() { // X轴随机偏移避免每次都撞同一个点 float randomX Random.Range(-2f, 2f); Vector3 pos new Vector3(randomX, spawnPos.y, spawnPos.z); GameObject obj Instantiate(obstaclePrefab, pos, Quaternion.identity); ObstacleMover mover obj.GetComponentObstacleMover(); if (mover ! null) mover.speed fallSpeed; } }为什么用协程而不是Update里的计时器StartCoroutine能把随机等待直接写在yield return里代码可读性好答辩时容易解释。InvokeRepeating虽然简单但只能固定时间重复做不到随机间隔。协程的while(true)必须有一个出口否则游戏结束后还会继续生成障碍物。所以在GameOver时调用StopAllCoroutines()或者在循环里判断GameManager.Instance.state不为Playing就break。另一个常见做法是给每个障碍物挂一个延迟销毁Destroy(obj, 10f)防止场景里累积物体拖慢运行。参数说明minInterval和maxInterval是随机间隔的上下限初始建议1到3秒fallSpeed建议4。生成位置spawnPos.z20X轴随机在-2到2之间基本覆盖玩家的活动范围。如果你后续做了难度管理器这里就用公共字段被外部覆盖不要在Spawner内部再写死难度。3.2 碰撞判定的两种模式触发器 vs 物理碰撞课设该选哪个Unity里碰撞判定有两种入口OnCollisionEnter和OnTriggerEnter。很多人一上来就写OnCollisionEnter然后发现玩家被障碍物弹飞或者两个物体粘在一起。普通碰撞会对两个刚体产生物理力躲避障碍游戏要的是“碰到就结束”不是“撞开再弹开”所以优先用触发器把障碍物的BoxCollider勾选IsTrigger玩家的BoxCollider不勾。触发器触发条件是至少一个物体有Rigidbody我们的玩家满足障碍物可以不带Rigidbody直接靠Transform移动。玩家身上挂PlayerDamage.csusing UnityEngine; public class PlayerDamage : MonoBehaviour { public GameManager gameManager; void OnTriggerEnter(Collider other) { if (other.CompareTag(Obstacle)) { // 撞到障碍物后禁用自己防止二次触发 gameObject.SetActive(false); gameManager.GameOver(); } } }注意用CompareTag而不是other.tag ObstacleCompareTag避免字符串比较产生的临时垃圾更重要的是不会因为Tag没定义就抛异常。你需要在Edit-Tags and Layers里先定义一个Obstacle标签然后给障碍物预制体设置Tag。这个遗漏非常常见如果碰撞没反应先检查Tag。还有一点gameObject.SetActive(false)是把整个玩家隐藏防止触发第二次但GameOver里如果还想读玩家位置会拿到空引用。更老练的做法是禁用PlayerController脚本而不是禁用整个GameObjectGetComponentPlayerController().enabled false;这样玩家的Cube还在场景里视觉上也看得见。如果你确实想用物理碰撞也可以但要额外处理障碍物加Rigidbody并勾选IsKinematic玩家Rigidbody的Constraints冻结Z轴位置然后写OnCollisionEnter。这样有两个坑Kinematic的障碍物需要Collider阻挡玩家玩家会撞在障碍物表面而不是穿过冻结Z轴后撞击瞬间的物理反馈可能把摄像机带偏。综合来看触发器实现简单、控制精确这是我把这个写法当标准答案的原因。3.3 计分与游戏状态UI数字滚轮能给你加分但先让数值可靠计分不要写在障碍物脚本里。做一个ScoreZone放在玩家身后比如(0, 0.5, -2)挂一个BoxCollider勾选IsTrigger。当障碍物穿过ScoreZone时加1分。这比在障碍物脚本里调用GameManager更合理因为障碍物会被销毁或池化而ScoreZone是固定的关卡物体。ScoreZone代码using UnityEngine; public class ScoreZone : MonoBehaviour { void OnTriggerEnter(Collider other) { if (other.CompareTag(Obstacle)) { GameManager.Instance.AddScore(1); } } }GameManager是全局唯一的。用单例模式Awake里给Instance赋值是Unity课设里最常见的状态管理方式不需要引入任何UI框架先把多个脚本能稳定访问同一个状态这件事做对。GameManager里保存分数、游戏状态、UI引用。建议用TextMeshPro而不是旧版TextTMP字体清晰打包后也不会变成糊的位图如果工程里没有TMP用Window-TextMeshPro-Import TMP Essential Resources导入命名空间记得用using TMPro;否则TextMeshProUGUI找不到类型。如果你想让答辩老师眼前一亮可以在加分时做一个数字滚轮效果旧分数翻到新分数。常见做法是协程在0.2秒内把显示值从旧分数插值到新分数每帧刷新文字。但我的建议是先把普通计分做对有空余时间再考虑滚轮。滚轮效果涉及旧值缓存和UI刷新时机属于另一组坑。不要在一开始就给自己加复杂度。4. 躲避障碍的常见“翻车点”碰撞失效、UI不刷新、物体穿模这一章说的都是实际做过的坑每一条都烧掉过一两个晚上。下面四个翻车点按“现象、原因、解决”来写按顺序排查大多数问题十分钟内能定位。4.1 碰撞没反应层级Layer与碰撞矩阵是第一个黑匣子现象玩家和障碍物交叉而过控制台没有任何OnTriggerEnter输出。原因一玩家Rigidbody的IsKinematic被勾上了。触发器触发要求至少一个Rigidbody是Dynamic非Kinematic如果两个都是Kinematic物理引擎默认不产生触发回调。原因二障碍物Collider没有勾IsTrigger或者根本不存在Collider。原因三Edit-Project Settings-Physics的Layer Collision Matrix里Player层和Obstacle层没有勾选。默认矩阵会勾Everything但如果你为性能关过层就会踩到这个坑。解决取消玩家的IsKinematic给障碍物预制体加BoxCollider并勾IsTrigger检查碰撞矩阵把Player和Obstacle两行之间勾上。检查步骤固定成一套先在Inspector里看玩家Rigidbody的Body Type是不是Dynamic再运行游戏Pause暂停看障碍物和玩家是否真的相交如果仍然没有触发在两个Collider上各写一个OnTriggerEnter脚本并Debug.Log这样能确定是物理层没触发还是回调没执行。之前遇到最离谱的一次是障碍物预制体上有一个Scale为0的CubeCollider还存在但体积是0所以看起来穿模了。检查Collider的size和Transform的scale都是基础却容易忽略的事。4.2 计分不刷新TMP组件引用错了现象AddScore被调用Debug.Log能打出自增后的分数但UI文字始终是0。原因大多出在引用上你把scoreText字段拖成了场景里一个旧Text组件但显示用的是TextMeshPro或者你把scoreText拖成了预制体里的UI而场景里运行的是另一个实例。解决统一用TMP并把Inspector里的引用拖到当前场景的实际物体。另一个容易忽略的坑UI在Canvas下面如果Canvas的Render Mode是Screen Space - Camera又被多个摄像机渲染可能被遮挡。可以先把Canvas Render Mode改成Screen Space - Overlay排除问题。TMP赋值文字用scoreText.SetText(score.ToString())直接赋string也可以但SetText性能更好TMP 4.x里也更推荐。4.3 打包到手机画面拉伸Canvas缩放模式不对现象Game窗口里切换分辨率都正常打包到Android手机后按钮被拉宽、文字变形。原因Canvas Scaler的UI Scale Mode默认是Constant Pixel Size它按绝对像素布局屏幕分辨率一换UI就错位。解决给Canvas加上Canvas Scaler组件UI Scale Mode改成Scale With Screen SizeReference Resolution设成设计稿分辨率比如1080x1920竖屏或1280x720横屏。Screen Match Mode建议用Shrink长边在超宽屏时不会被裁切。参数表Canvas Scaler 模式适用场景常见问题Constant Pixel Size固定目标分辨率其他分辨率下位图糊、布局错位Scale With Screen Size手机/自适应UI需要设置参考分辨率否则仍可能拉伸Constant Physical Size物理尺寸一致游戏UI一般不推荐如果还遇到画面拉伸而非UI拉伸那检查的是Player Settings里的Resolution Scaling不是Canvas。另一个小坑是手机屏幕上的黑边裁剪来自Display Cutout设置在Player Settings里把Display Cutout设为Never安全区域就不会遮挡UI。4.4 材质变成紫红色Shader丢失不是显卡坏了现象进入Play或打包后场景里一个或多个物体变成紫红色。原因在URP工程里旧材质用的Standard Shader没有被URP兼容URP会把它显示为洋红色纯属Shader替换问题另一种是打包时Shader没有被包含进包常见于动态创建的材质或者Asset Store导入的资源。解决先看工程使用的管线。如果是URP用Window-Rendering-Render Pipeline Converter把内置材质转成URP材质如果不想用URP回到Unity Hub换回3D Core模板。对于动态创建的材质尽量引用一个在场景里用过的材质而不是new Material(Shader.Find(...))避免Shader在Build时被裁剪。紫红色的“治疗”并不神秘但一定要知道它跟显卡无关否则会浪费一个下午查显卡驱动。5. 让课设从“能跑”到“能讲”UI状态机与Easy模式参数设计5.1 用枚举管理游戏状态这比一堆bool时髦且不容易漏很多课设代码里用bool isGameOver; bool isStart; bool isPaused;一旦逻辑多了就会漏改。我建议用一个枚举GameState来管理写进GameManager里using UnityEngine; using TMPro; public enum GameState { Menu, Playing, GameOver } public class GameManager : MonoBehaviour { public static GameManager Instance; public GameState state GameState.Menu; public GameObject menuPanel; public GameObject gameOverPanel; public TextMeshProUGUI scoreText; private int score; void Awake() { Instance this; } public void StartGame() { state GameState.Playing; score 0; scoreText.SetText(0); menuPanel.SetActive(false); gameOverPanel.SetActive(false); } public void AddScore(int value) { if (state ! GameState.Playing) return; score value; scoreText.SetText(score.ToString()); } public void GameOver() { if (state GameState.GameOver) return; state GameState.GameOver; Time.timeScale 0f; gameOverPanel.SetActive(true); } public void Restart() { Time.timeScale 1f; UnityEngine.SceneManagement.SceneManager.LoadScene( UnityEngine.SceneManagement.SceneManager.GetActiveScene().name); } }这个GameManager兼任最简单的UI框架菜单面板、结束面板和计分都从它一条路走。状态机的价值在于AddScore会先判断state不是Playing就return避免游戏结束瞬间分数还在涨GameOver也判断防重复调用。答辩时你说“游戏有明确状态迁移”比说“我用了三个bool”要加分得多。菜单界面的按钮在OnClick里绑定StartGame和Restart即可不需要写额外代码。5.2 难度曲线用AnimationCurve代替拍脑袋调参躲避障碍最容易被人批评“没有难度变化”。改法不是写一堆if而是在Inspector里画两条曲线一条控制障碍物移动速度一条控制生成间隔。代码用AnimationCurve字段图形化调参using UnityEngine; public class DifficultyManager : MonoBehaviour { public AnimationCurve speedCurve; public AnimationCurve intervalCurve; public float maxTime 60f; private float elapsed; private ObstacleSpawner spawner; void Start() { spawner FindObjectOfTypeObstacleSpawner(); // 曲线没赋值时给默认避免Evaluate抛错 if (speedCurve.keys.Length 0) { speedCurve AnimationCurve.Linear(0f, 4f, 1f, 8f); } if (intervalCurve.keys.Length 0) { intervalCurve AnimationCurve.Linear(0f, 2f, 1f, 0.8f); } } void Update() { elapsed Time.deltaTime; float t Mathf.Clamp01(elapsed / maxTime); spawner.fallSpeed speedCurve.Evaluate(t); spawner.minInterval intervalCurve.Evaluate(t) * 0.8f; spawner.maxInterval intervalCurve.Evaluate(t) * 1.2f; } }注意Start里缓存spawner别在Update里FindObjectOfType那会每帧全场景搜索到答辩机器上会卡。曲线如果没赋值Evaluate直接抛错所以给默认值。maxTime决定了难度从0爬到峰值的时间我建议60秒正好是一节课的答辩节奏。两条曲线的横轴都是0到1纵轴分别是速度和间隔。速度线性从4涨到8间隔从2秒缩到0.8秒初期压力不大30秒后需要专注。用这个脚本接管之前写死的生成参数Game窗口里能实时看到障碍物加速。AnimationCurve的可视化调参是Unity里最值得炫耀的能力之一答辩时当场拖着曲线演示手感变化非常稳妥。5.3 躲避成功的判定ScoreZone是什么为什么障碍物自己不加分还有一个常见问题是“分数什么时候加”。如果在障碍物生成时就把分数加1玩家躲不躲都有分如果在碰撞时加分那是死亡分而不是躲避分。正确的做法是加一个ScoreZone一个放在玩家身后、垂直于移动方向的触发器区域。障碍物从玩家面前飞过最后会穿过ScoreZone一旦命中说明玩家成功躲过了这一根。ScoreZone不关心障碍物从左边还是右边来只关心它有没有完整通过玩家身后。代码using UnityEngine; public class ScoreZone : MonoBehaviour { void OnTriggerEnter(Collider other) { if (other.CompareTag(Obstacle)) { GameManager.Instance.AddScore(1); } } }ScoreZone的Collider要比玩家稍宽例如(6, 2, 1)这样障碍物从侧面擦过也能计入。有一个逻辑顺序可以放心玩家在ScoreZone前面障碍物先撞上玩家游戏就结束没撞到玩家的障碍物才会继续飞到ScoreZone。如果还是不放心可以在ObstacleMover里加一个bool scoredScoreZone计分后置为true防止重复加分。数字滚轮效果如果要做加在ScoreZone的AddScore之后。原理是协程从旧分数插值到新分数每帧用SetText刷新TMP持续0.2到0.3秒。这里不铺开代码因为如果滚动逻辑写错了会干扰核心玩法。先把计分做对锦上添花放在打包之前。6. 最后一道关卡打包成可交付的exe并在10分钟内自测6.1 打包前的三项设置和 10 分钟自测清单File-Build Settings把当前场景拖进Scenes列表Target Platform选Windows。Player Settings里切到Windows填好Company Name、Product Name、Default Icon。分辨率建议默认1280x720窗口化不要勾Start Fullscreen否则老师中途切屏会烦。压缩方式LZ4即可。Build之后把整个Build文件夹压缩成zip而不是只给一个exe否则别人缺少UnityPlayer.dll和Data目录打不开。自测清单长这样进入项目先确认菜单按钮能点开始游戏后30秒内没有报错撞到障碍物时GameOver面板弹出点击Restart能重开分数随躲避增加在另一台没装Unity的电脑或虚拟机里跑exe确认依赖完整。如果需要减小包体启用Player Settings里的Strip Engine Code但前提是代码没有依赖反射如果用了第三方插件先关掉。Unity包体优化不是课设重点除非老师明确要求限制大小。调试信息可以包一层宏定义#if UNITY_EDITOR Debug.Log(只在编辑器里输出); #endif最后分享一个我的后悔药以前做课设我总是在编辑器里玩一局觉得顺就直接交源码结果Demo在别人的电脑上打开白屏。后来我养成习惯提交前一天强制导出一遍exe并在干净环境里完整跑一局。编辑器里正常不算数打包后一切才算数。希望帮到你。本文还有配套的精品资源点击获取