简介:《Unity 3D期末作业:太空飞机射击游戏》是一份面向Unity初学者、高校游戏专业学生及期末大作业参考者的完整项目资源。该项目围绕太空背景下的飞机射击玩法,涵盖场景搭建、C#脚本控制、子弹对象池、碰撞检测、音频播放、UGUI界面、动画系统与游戏状态管理等核心知识点,适合用来自学复盘或作为课设改造基础。
包体为zip压缩包,共11022个文件,约87.43MB。内部以1413个C#脚本、672张PNG图片、138个DLL库和33个材质文件为主,同时包含FBX模型、Prefab预制体、MP3/WAV音频及Unity场景资源,可清晰看出工程目录结构与资源组织方式。
该资源已有1991人学习下载,其中C#脚本与场景配置覆盖了从角色移动到射击反馈的完整链路。通过查看工程结构、阅读脚本注释与资源文件,可以快速理解一个功能完整的Unity小游戏是如何分层设计与组织代码的,也能为提交期末作业提供可直接借鉴的模块划分与实现思路。
1. 太空飞机射击游戏:Unity期末作业怎么从零拼出一个能跑的 Demo
期末前一晚对着 Unity 空场景发愁的人不止你一个。说是“太空飞机射击游戏”,拆开看其实就是八个模块:场景、飞机控制、子弹、碰撞、计分、UI、音频、状态管理。这份作业资源的思路是先把功能拆成独立脚本,再靠 GameManager 串联起来,而不是把代码全堆在飞机上。适合想快速搭出可演示版本、又想把关键逻辑讲明白的人。引擎版本建议 2021 LTS 以上,全程不用第三方插件,纯 C# 和内置组件就能完成。
技术栈不复杂:Transform 控制位移、Rigidbody 参与碰撞、Instantiate 生成子弹、UGUI 显示分数、AudioSource 播音效。真正拉开作业档次的是对象池、单例管理器和事件通知这三个设计手段。下面按模块逐个落地。
2. 场景与飞机控制:坐标系、刚体与输入映射的三个关键点
2.1 太空场景:不用美术资源也能做出纵深感的三个手段
太空场景最怕空旷。常见做法是用 Camera 的 Background 属性给深蓝色底,再叠两层粒子系统:一层静态星空作为远景,一层缓慢飘移的星尘给画面动感。没有现成贴图时,粒子系统里把 Start Size 设成 0.05 左右、Start Speed 设成 0,只保留大量静态小点,效果就足够应付演示了。
行星和障碍物可以用 Unity 内置的 Sphere、Cube 加上 Standard 材质里的 Emission 自发光来冒充。我给场景里放了几个不发光的球体当陨石,挂上 Rotate 脚本让它们自转,视觉上就有“太空漂浮感”。光照方面建议把场景主光源设成 Directional 并调低强度,否则默认平行光会把太空场景照得像办公室。
using UnityEngine; public class RotateSelf : MonoBehaviour { public Vector3 rotateSpeed = new Vector3(0f, 30f, 0f); void Update() { transform.Rotate(rotateSpeed * Time.deltaTime); } }这段脚本挂在陨石或行星上,rotateSpeed 按轴控制旋转快慢,乘上 Time.deltaTime 是为了让旋转速度不依赖帧率。注意如果物体层级里有父节点,Rotate 默认绕自身坐标系转,想要绕世界轴转就改成Space.World。
2.2 飞机控制:为什么用 Transform 而不用 AddForce
飞机是玩家直接操作的载体,控制手感是第一个体验分水岭。业界常用的有两种方案:Transform 直接改位置,或者 Rigidbody 加力。纯逻辑上 Transform 方案简单直观,但飞机撞上障碍物时会被 Collider 卡住甚至穿透;Rigidbody 方案有物理反馈,但调参不好会让飞机像在水里滑行。
这个作业场景我建议折中:飞机挂 Rigidbody,但把interpolation设为 Interpolate 防止画面抖动,collisionDetectionMode设为 Continuous 避免高速穿透,然后直接用 transform.Translate 控制移动。Rigidbody 负责碰撞响应,Translate 负责手感,两者不冲突。边界限制用 Mathf.Clamp 做硬约束,比写围墙 Collider 更省事。
using UnityEngine; public class PlayerController : MonoBehaviour { public float speed = 8f; public float boundaryX = 8f; public float boundaryY = 4.5f; void Update() { float h = Input.GetAxis("Horizontal"); float v = Input.GetAxis("Vertical"); Vector3 move = new Vector3(h, v, 0f) * speed * Time.deltaTime; transform.Translate(move, Space.World); Vector3 pos = transform.position; float clampedX = Mathf.Clamp(pos.x, -boundaryX, boundaryX); float clampedY = Mathf.Clamp(pos.y, -boundaryY, boundaryY); transform.position = new Vector3(clampedX, clampedY, pos.z); } }Input.GetAxis 返回 -1 到 1 的平滑插值,比 GetKey 直读按键的手感更顺滑。speed 设 8、Time.deltaTime 让移动速度与帧率解耦。boundary 建议先用 Scene 视图手动摆飞机位置试出边界,再填进 Inspector——直接用坐标硬估容易把飞机卡在半空。Translate 的 Space.World 保证飞机始终沿世界坐标移动,避免飞机旋转后方向轴错乱。
2.3 为什么飞机要锁 Z 轴:2.5D 视角的坐标陷阱
做太空射击最容易翻车的地方是 Z 轴。摄像机摆在一个斜向视角时,玩家按上下键,直觉是飞机在屏幕里上下飞,但实际改变的是世界 Y 或 Z。解决方案是摄像机用正交模式(Orthographic),飞机只允许在 XY 平面移动,Z 固定,这样屏幕坐标和世界坐标的映射关系就清晰了。
void LateUpdate() { transform.position = new Vector3(transform.position.x, transform.position.y, -5f); }这段放在飞机控制脚本末尾,每帧强制锁定 Z 值。注意要用 LateUpdate 而不是 Update,因为 Update 里可能有其他脚本移动飞机位置,LateUpdate 在所有 Update 结束后执行,锁定的才是最终位置。否则多个脚本同时改位置,Z 轴会被最后执行的那个脚本覆盖,表现为飞机乱穿模。
3. 射击系统与对象池:把子弹从“new 到爆”改成“循环复用”
3.1 子弹发射:谁生成、谁销毁、谁负责速度
射击逻辑的经典做法是把三件事拆开:子弹预制体只管飞行和自毁,玩家脚本只管生成和方向,碰撞脚本只管击中反馈。预制体上挂 Rigidbody 并关闭 Use Gravity,再写一段子弹脚本让它沿自身前方移动。
子弹移动有两种写法,一种是改 transform 位置,另一种是 Rigidbody.velocity。前者适合高速子弹,后者适合需要重力或物理反弹的场景。太空射击里子弹速度通常很快,我推荐直接在 Start 里赋速度,避免每帧计算移动方向。
using UnityEngine; public class Bullet : MonoBehaviour { public float speed = 15f; public float lifeTime = 2f; void Start() { GetComponent<Rigidbody>().velocity = transform.forward * speed; Destroy(gameObject, lifeTime); } }transform.forward 是子弹预制体自身的朝向,所以发射前要先保证预制体的 Z 轴指向镜头方向或飞行方向。lifeTime 是容错机制,防止子弹飞出边界后永不销毁造成内存泄漏。常见错误是忘了给子弹加 Collider,或者把 Collider 设成了 Trigger 但碰撞回调写的是 OnCollisionEnter,导致永远打不中敌人。
3.2 玩家射击脚本与冷却时间
按住空格连续发射是射击游戏的基本操作,但如果不加限制,Update 每帧都生成子弹,子弹数量会指数爆炸。冷却时间的标准做法是用 Time.time 和 lastShootTime 做差值判断。
using UnityEngine; public class PlayerShoot : MonoBehaviour { public GameObject bulletPrefab; public Transform firePoint; public float fireRate = 0.15f; private float lastShootTime; void Update() { if (Input.GetButton("Fire1") && Time.time - lastShootTime >= fireRate) { Shoot(); lastShootTime = Time.time; } } void Shoot() { GameObject bullet = Instantiate(bulletPrefab, firePoint.position, firePoint.rotation); bullet.transform.SetParent(null); } }fireRate 是每次射击的最小间隔,0.15 秒意味着每秒约 6.7 发。Input.GetButton 检测长按,GetButtonDown 只检测按下瞬间,射击游戏用 GetButton 更符合“按住连发”的习惯。firePoint 建议在飞机模型机头处创建一个空物体作为发射口,这样调整枪口位置不需要改代码。Instantiate 之后 SetParent(null) 是把子弹从玩家层级下脱离,否则子弹会跟随玩家移动,出现子弹轨迹偏移。
3.3 对象池:为什么期末作业也要做这层优化
很多教程会用Destroy(gameObject, 1f)让子弹消失,如果射击频率高,场景里会频繁创建和销毁对象,垃圾回收(GC)卡顿会变得肉眼可见。期末演示时如果同时存在几十颗子弹加上爆炸特效,帧率可能从 60 掉到 20。
对象池的核心思想是预创建一批子弹放进容器,发射时取出、命中后回收,而不是销毁。多数通用对象池组件可以直接挂到场景的 GameManager 上。实现思路是维护一个Queue<GameObject>列表,取出时设为激活状态,回收时设为隐藏状态并重置速度。
using System.Collections.Generic; using UnityEngine; public class BulletPool : MonoBehaviour { public GameObject bulletPrefab; public int poolSize = 30; private Queue<GameObject> pool = new Queue<GameObject>(); void Start() { for (int i = 0; i < poolSize; i++) { GameObject bullet = Instantiate(bulletPrefab); bullet.SetActive(false); pool.Enqueue(bullet); } } public GameObject GetBullet() { if (pool.Count > 0) { GameObject bullet = pool.Dequeue(); bullet.SetActive(true); return bullet; } return null; } public void ReturnBullet(GameObject bullet) { bullet.SetActive(false); pool.Enqueue(bullet); } }这段是精简演示版,实际项目中 GetBullet 还要处理位置和速度的重置,否则子弹回收再取出时还带着上次的飞行速度。poolSize 建议根据测试时的最大同时存在子弹数来设,多留 20% 余量。对象池的代价是代码复杂度上升,期末作业如果射击频率不高、游戏时长不超过三分钟,也可以不做池化,直接 Instantiate。但如果做完对象池再去掉会很可惜,因为答辩时老师大概率会问性能优化——答出“用池化避免 GC 压力”,印象分直接不一样。
4. 碰撞、计分与游戏状态:GameManager 单例与事件驱动的完整闭环
4.1 碰撞检测:OnTriggerEnter 还是 OnCollisionEnter,选错就失灵
碰撞是射击游戏里最容易“看起来没问题但实际触发不了”的模块。Unity 里碰撞分两种:物理碰撞和触发器。物理碰撞要两个物体都有 Collider 且至少一个挂 Rigidbody,触发回调是 OnCollisionEnter;触发器需要把 Collider 的 Is Trigger 勾上,回调是 OnTriggerEnter。
子弹打敌人的场景,推荐子弹和敌人都用 Trigger,因为 Trigger 不产生物理反弹,逻辑也更轻。但千万注意:Trigger 模式下,必须有一个 Rigidbody 参与检测,通常挂在子弹上,否则回调不会触发。另一个坑是 Collider 层,默认的 Everything 层没问题,别手滑把子弹的 Layer 改成 Ignore Raycast,碰撞检测会跳过它。
using UnityEngine; public class EnemyCollision : MonoBehaviour { private void OnTriggerEnter(Collider other) { if (other.CompareTag("Bullet")) { Destroy(gameObject); GameManager.Instance.AddScore(10); } } }CompareTag 比other.tag == "Bullet"更安全,因为 tag 不存在时前者返回 false 而后者直接报错。这段逻辑放在敌人身上,碰撞回调里同时完成“销毁敌人”和“通知计分”两个职责。如果你把脚本挂在子弹上,记得改成 OnTriggerEnter 里判断 other 的 tag 是否是 Enemy,两种写法效果等价,关键是保持一致,别两边都写导致重复计分。
4.2 GameManager 单例:为什么全局状态要集中管理
当分数、游戏状态、UI 刷新、音效播放散布在各个脚本里时,调试会变成一场灾难。一个常见的设计是做一个全局唯一的 GameManager,负责分数、游戏状态(开始/结束/暂停)和事件通知。所谓单例,就是类里有个静态实例,Awake 时把自己赋值给它,并防止切换场景时被销毁。
using UnityEngine; using UnityEngine.SceneManagement; public class GameManager : MonoBehaviour { public static GameManager Instance { get; private set; } public int score { get; private set; } public bool isGameOver { get; private set; } private void Awake() { if (Instance != null && Instance != this) { Destroy(gameObject); return; } Instance = this; DontDestroyOnLoad(gameObject); } public void AddScore(int points) { score += points; UIManager.Instance.UpdateScore(score); } public void GameOver() { isGameOver = true; UIManager.Instance.ShowGameOver(); } }Instance 属性用{ get; private set; }防止外部随意替换,score 同理。DontDestroyOnLoad 是为了跨场景保存数据,如果这个作业只有一个场景,可以不加。Awake 里的判空逻辑是单例的标准防御:如果场景里误放了多个 GameManager,销毁后来者保留先者。这套设计的好处是,所有脚本要加分时只调GameManager.Instance.AddScore(10),要结束游戏时只调GameManager.Instance.GameOver(),职责边界非常清楚。
4.3 事件通知:UI 不轮询、音效不硬编码
如果 UIManager 每个 Update 都去读 GameManager.score,这叫轮询,能用但浪费。更好的做法是事件驱动:GameManager 在分数变化时发一个事件,UIManager 订阅这个事件,只在被通知时才刷新显示。
using System; using UnityEngine; public class GameManager : MonoBehaviour { public static event Action<int> OnScoreChanged; public static event Action OnGameOver; public void AddScore(int points) { score += points; OnScoreChanged?.Invoke(score); } }对应的 UIManager 在 Awake 里订阅,在 OnDestroy 里取消订阅。取消订阅这一步不能省,否则场景切换时旧 UI 还挂着事件引用,会报“MissingReferenceException”或者导致重复刷新。事件驱动的好处是解耦:以后新增成就系统、关卡系统,只需要多订阅一个事件,不用改 GameManager 的代码。
订阅事件的完整写法:
using UnityEngine; using TMPro; public class UIManager : MonoBehaviour { public TextMeshProUGUI scoreText; private void OnEnable() { GameManager.OnScoreChanged += UpdateScore; GameManager.OnGameOver += ShowGameOver; } private void OnDisable() { GameManager.OnScoreChanged -= UpdateScore; GameManager.OnGameOver -= ShowGameOver; } private void UpdateScore(int value) { scoreText.text = "Score: " + value; } private void ShowGameOver() { // 显示结束菜单,暂停游戏逻辑 } }注意事件写法是Action<int>带参委托,订阅方法必须接收一个 int 参数。如果事件的参数数量和订阅方法不匹配,编译时报错会提示“方法没有重载匹配委托”,这时候去检查是不是忘了写参数。这种事件模型在答辩时很好讲:当被问到“UI 如何实时更新分数”,回答“通过事件通知,UI 只响应变化,不轮询”,比“我在 Update 里每帧读分数”明显高一个层次。
5. 常见问题与排查:五个必踩的 Unity 坑与解决记录
5.1 子弹看不见:预制体尺寸太小或方向不对
现象:按下发射键没有子弹出现,或者子弹刷在屏幕外。 原因归纳起来有三类:预制体 scale 太小(1 立方体在远处几乎看不见);子弹出生点 firePoint 与摄像机之间被飞机模型遮挡;预制体的 Z 轴方向与飞行方向不一致,子弹向场景深处飞去。
排查时先在 Scene 视图里点击 Play 暂停,看 Hierarchy 里有没有子弹生成。生成了但看不见,就把摄像机切到 Scene 视图沿子弹方向看。解决方法是给子弹预制体挂一个点光源或者 Scale 调到 0.2 以上,firePoint 放在飞机机头前方 1 个单位处,并且发射时统一用transform.forward作为方向基准。
5.2 碰撞不触发:回调写错或 Rigidbody 缺失
现象:子弹穿过敌人,没有销毁也没有加分;或者玩家飞机撞上障碍物无事发生。 最常见的三种原因:写的是 OnTriggerEnter 但 Collider 没勾 Is Trigger;子弹没有挂 Rigidbody;两个物体不在同一个碰撞层。
排查顺序我一般这样走:先看两个物体 Inspector 里 Collider 的 Is Trigger 是否一致,再看至少一个物体有没有 Rigidbody,最后在 Console 里加调试日志Debug.Log(other.name)确认回调是否被调用。还有一个隐蔽问题:父物体挂 Collider 但子物体挂 Rigidbody,Unity 的碰撞检测会失效,需要把 Collider 和 Rigidbody 放在同一层级。
5.3 Time.deltaTime 没乘导致穿模
现象:飞机高速撞向障碍物直接穿透,或者子弹打中敌人后弹到另一侧。 原因:位移计算没有乘 Time.deltaTime,导致高帧率下移动速度是低帧率的好几倍,碰撞检测的采样步长越过目标。Rigidbody 的 Collision Detection 模式默认是 Discrete,速度太快会漏帧。
解决方法是先给 Rigidbody 的 collisionDetectionMode 设成 Continuous,再把位移计算统一乘上 Time.deltaTime,最后把 Max Depenetration Velocity 调高一点,防止物体相互嵌入后弹飞。速度超过 30 的场景还建议把 Fixed Timestep 从 0.02 调低到 0.01,物理采样频率加倍,代价是 CPU 负载上升。
5.4 分数不刷新:事件没监听或 Text 组件绑定错
现象:击毁敌人后分数增加,但屏幕上的 UI 没变化。 原因:UIManager 的 OnEnable 里订阅事件晚于 GameManager 发事件;Inspector 里 scoreText 没有从场景拖入;或者用了旧版的 Text 组件而代码引用的是 TextMeshPro。
排查顺序:先确认 GameManager 的 AddScore 是否在 Enemy 的碰撞回调里被调用;再检查 UIManager 组件的 scoreText 字段是否为空;最后看 Console 报错是不是“NullReferenceException”。如果是 TMP 和 Text 混用,直接统一换 TextMeshPro,因为后者支持更丰富的排版,而且现在的 Unity 新项目默认用 TMP。
5.5 游戏结束相机仍然跟随,敌人还在攻击
现象:玩家被击毁后,界面弹出但场景里的敌人还在移动和射击。 原因:GameOver 只改了 isGameOver 布尔值,但敌人、子弹、障碍物的脚本没有检查这个状态,也没有暂停操作。 解决方法是加一个 Time.timeScale = 0f 来暂停全部 Update 逻辑,同时把相机脚本的判断条件加上if (GameManager.Instance.isGameOver) return;。
public void GameOver() { isGameOver = true; Time.timeScale = 0f; UIManager.Instance.ShowGameOver(); }用 Time.timeScale 暂停的坑是重启时要记得恢复为 1f,否则整个游戏永久卡死。恢复方法放进重新开始菜单按钮的回调里,而不要放在 Start,因为 Start 只执行一次。
6. 进阶打磨:从“能跑”到“能演示”的四个验证手段
期末作业能不能拿高分,不在于功能多,而在于演示时是否流畅、逻辑是否自洽、“如果被问到某个细节能不能讲清楚”。我给自己定了一套验证流程,每次演示前强制走一遍。
第一个验证点是帧率。打开 Game 视图右上角的 Stats 面板,看 FPS 是否稳定在 60。如果波动大,优先检查是否有 Instantiate 和 Destroy 频繁交替的物体,把它们换进对象池,再把阴影质量调低一档。太空场景是粒子系统大户,建议把粒子最大数控制在 300 以内,否则低配电脑上演示现场掉帧很尴尬。
第二个验证点是复位逻辑。结束游戏后能不能一键重新开始?我习惯写一个 RestartGame 方法,在 GameManager 里重置分数、销毁场上所有残留子弹和敌人、恢复 timeScale,然后重新加载场景。重新加载场景比手动清理更省事,SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex)一行搞定,注意要配合 DontDestroyOnLoad 的 GameManager 去重逻辑。
public void RestartGame() { Time.timeScale = 1f; score = 0; SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex); }第三个验证点是音效触发链路。子弹发射音效挂在射击脚本里播放,爆炸音效挂在敌人销毁时播放,这些节点很容易因为音频源挂错位置导致音量不对。我的习惯是建一个独立的 AudioManager 对象,所有音效通过它统一播放,这样声音大小、播放方式都在一处调整,不用翻遍各个脚本找 AudioSource。
第四个验证点最容易被忽略:把 Build Settings 里的场景路径从 0 号场景开始排。很多同学在编辑器里按 Play 一切正常,打包后游戏一启动就黑屏或卡在初始画面,原因就是构建时场景顺序不对或某个场景没加入构建列表。打包前检查一下 File -> Build Settings,确认要发布的场景勾选且顺序正确,再做一次 Windows 平台的 Build 验证。调整任何预制体或脚本后,Build 一次的成本只有十几秒,但能提前拦住“打包后碰撞失效”“打包后中文字体丢失”这类现场翻车。
最后说个我自己的习惯:每次演示前,我把 Game 视图分辨率调成 16:9 的 1280x720,关掉 Console 面板里的 Clear on Play,再把 Time.timeScale 临时设为 2 倍速跑 30 秒——这能暴露绝大多数碰撞和事件时序问题。从那以后,我每次做 Unity 期末作业都强制走一遍这套验证,翻车率直线下降。希望这份拆解能帮你把作业从“能跑”变成“经得起问”,祝你答辩顺利。
本文还有配套的精品资源,点击获取