☰
Unity 3D期末作业指南:太空飞机射击游戏实现与避坑要点
2026/10/10 3:08:24 网站建设 项目流程

简介:这是一份基于Unity 3D引擎开发的太空飞机射击游戏期末作业完整工程,适合Unity初学者、游戏开发相关专业学生及需要完成大作业实践项目的开发者参考学习。项目围绕太空背景场景搭建、飞机移动控制、子弹射击与碰撞检测、计分系统、游戏状态管理、音效播放等核心模块展开,覆盖了C#脚本编写、Animator动画控制、UGUI界面设计、Rigidbody物理组件应用等关键技能,能够帮助读者系统理解一款小型3D射击游戏从场景搭建到功能实现的全流程。

压缩包约87.43MB,共包含11022个文件,主要涉及C#脚本(1413个)、meta资源信息(2536个)、png纹理素材(672个)、Unity场景/预制体/材质等工程资产以及dll运行库,目录结构较为完整。项目可直接用Unity打开查看完整实现,从飞机移动、射击逻辑到碰撞销毁、分数UI均有对应代码与资源支撑;已有1991人学习浏览,适合想通过完整示例快速上手Unity开发、参考射击类游戏核心逻辑与工程组织的同学使用。

1. Unity 3D期末作业做太空飞机射击游戏:为什么它是最不容易翻车的选题

Unity 3D期末作业如果只让我推荐一个稳的方向,太空飞机射击游戏一定排前三。它的画面够炫、玩法边界清晰,哪怕只做“移动、发射、敌机、碰撞、计分”这五件事,演示效果也远好过一堆半成品功能点。更关键的是,这个品类对美术资源要求极低,用引擎自带的几何体加一点粒子效果就能撑起全场,新手从零开始,一周课余时间就能跑出一个能演示、能交差、能讲清设计思路的成品。这篇笔记适合三类人:第一次碰 Unity 想选个稳选题的学生、想给期末项目加一点可玩性但怕玩脱的人,以及帮别人看项目时想快速定位问题的同学。

2. 动手前先定三件事:视角方案、碰撞层级与最小玩法闭环

2.1 第三人称追尾视角:为什么课堂演示最吃香

太空射击游戏通常有两种视角:第一人称座舱视角和第三人称追尾视角。期末作业我一般直接建议做第三人称追尾。第一人称需要处理仪表盘、瞄准框、机舱遮挡,美术和交互成本都高,演示时观众隔着一个屏幕也看不清细节。追尾视角最大的好处是“你看到的飞机就是裁判看到的飞机”,操作反馈直观,子弹轨迹、敌人来袭方向、爆炸范围一目了然。

追尾视角还有一个隐藏优势:相机不需要自己写旋转逻辑,只要把 Main Camera 挂在飞机后面,用 LateUpdate 跟随位置就行。后期想换成立式弹幕玩法,只需要把相机挪到侧面,代码改动量极小。对比之下,第一人称视角至少要多写一套相机抖动和视野缩放逻辑,对期末作业来说属于纯消耗。

相机参数上,我一般会把相机放在玩家后上方约 6 到 10 个单位的距离,偏移量设为(0, 2.5, -8)。z 轴偏移负值表示相机在飞机后方,y 轴 2.5 保证能看到飞机背部和前下方的敌机。视角范围 Field of View 设在 55 到 60 度之间,太宽会出现边缘畸变,太窄会看不清两侧来袭的敌人。

2.2 碰撞层级与物理配置:新手最容易埋雷的地方

很多同学把脚本挂好、预制体拖好,一运行却发现子弹没打中敌人,或者飞机被自己的子弹一击秒杀。问题几乎都出在碰撞配置上。Unity 默认所有物体都在 Default 层级,子弹、敌机、玩家互相触发,你根本没法区分“玩家撞敌人”和“子弹撞玩家”。

我建议开局就把玩家的层级设为 Player,子弹设为 Bullet,敌机设为 Enemy,UI 和墙体放在 Environment。然后在 Edit -> Project Settings -> Physics 里打开层碰撞矩阵,把 Bullet 设置成只与 Enemy 碰撞。这个操作比写一百行 if 判断都管用,而且一劳永逸。

碰撞体选择也有讲究。飞机和敌机这类造型复杂的模型,用 Mesh Collider 听着精确,但性能差且容易和子弹的 Trigger 检测产生漏判。用 Box Collider 或 Capsule Collider 包裹飞机主体就够了,子弹用自己的小 Box Collider 并勾选 Is Trigger。这样OnTriggerEnter检测稳定,物理模拟开销也可以忽略不计。

2.3 最小玩法四件套:移动、开火、生成、销毁

一个能当期末作业交上去的太空飞机射击游戏,最少得具备四个系统:玩家移动、玩家开火、敌机生成、碰撞销毁。这四个系统彼此独立,又能串成一条完整的游戏循环。我写项目时习惯先把这四个系统的最小版本跑通,再回头加计分、特效、音效。代码如下,这是玩家控制脚本的完整最小版本:

using UnityEngine; public class PlayerController : MonoBehaviour { public float speed = 15f; // 巡航速度,单位是 Unity 单位/秒 public float boundaryX = 9f; // 水平边界,防止飞出屏幕 public float boundaryY = 5f; // 垂直边界,防止飞到画面外 public GameObject bulletPrefab; // 子弹预制体,拖拽赋值 public Transform firePoint; // 弹口位置,放在机头前方 void Update() { // 键盘输入,横轴和纵轴范围都是 -1 到 1 float h = Input.GetAxis("Horizontal"); float v = Input.GetAxis("Vertical"); // 移动:Time.deltaTime 保证帧率不同时速度一致 Vector3 move = new Vector3(h, v, 0f) * speed * Time.deltaTime; transform.position += move; // 边界钳制:超过边界就拉回最边缘 float x = Mathf.Clamp(transform.position.x, -boundaryX, boundaryX); float y = Mathf.Clamp(transform.position.y, -boundaryY, boundaryY); transform.position = new Vector3(x, y, transform.position.z); // 发射:默认鼠标左键或空格键 if (Input.GetButtonDown("Fire1") || Input.GetKeyDown(KeyCode.Space)) { Instantiate(bulletPrefab, firePoint.position, firePoint.rotation); } } }

这段代码的几个关键参数值得说明。speed 设 15 是经验值,在太空场景中这个速度配合追尾视角会显得轻快又不至于失控,如果你做的是弹幕玩法可以降到 8 到 10。boundaryX 和 boundaryY 要根据实际相机视野调整,正交相机视野里边界大约是9f和5f,透视相机则需要先跑一遍看飞机到屏幕边缘再回来微调。发射采用GetButtonDown而不是GetButton,因为按住连发在最小版本里不需要,等后面做道具系统再加入自动连发。

3. 让飞机飞起来:移动手感、边界钳制与相机跟随

3.1 直接用 Transform 还是刚体:期末作业的取舍

移动手感的第一个争论点是“用 Transform 移动还是用 Rigidbody 移动”。对这个项目,我建议直接改 Transform,除非你的飞机需要做碰撞反弹或者被爆炸冲击波推开。原因有两个:一是期末作业的物理场景极其简单,刚体带来的真实受力在这里体现不出来;二是刚体移动容易在低帧率时出现穿透或抖动,排查问题的时间远超省下的那点代码量。

但要注意,直接改 Transform 的飞机是可以和敌人碰撞体相交的。所以碰撞销毁逻辑必须依赖OnTriggerEnter或OnCollisionEnter来显式处理,不能让物理系统帮你解决一切。如果你在飞机上挂了 Rigidbody 且没有勾选 Is Kinematic,反而会引入额外的受力,玩家按方向键时会产生延迟感。我见过一个朋友的飞机在按下方向键后要半秒才有反应,排查半天发现是刚体质量设成了 0.01,惯性太大。

3.2 边界钳制与加速度:三行代码救回手感

太空射击最影响手感的问题是“飞机像在冰面上滑行”。这通常是因为代码直接修改位置,而玩家按下的方向键弹起、落下都有瞬时值,导致飞机立刻达到最大速度再立刻减到零。用 Unity 自带的Mathf.SmoothDamp可以让加速度和减速度都有过渡,过程大概三行:

public float smoothTime = 0.15f; // 达到目标速度所需时间,越小越跟手 private Vector3 velocityRef = Vector3.zero; private Vector3 targetPos; void Update() { float h = Input.GetAxis("Horizontal"); float v = Input.GetAxis("Vertical"); // 目标位置:原位置加上这一帧期望的位移 targetPos = transform.position + new Vector3(h, v, 0f) * speed * Time.deltaTime; // SmoothDamp 会生成一个带缓冲的中间位置,消除冰面手感 transform.position = Vector3.SmoothDamp( transform.position, targetPos, ref velocityRef, smoothTime); // 边界钳制仍然保留 float x = Mathf.Clamp(transform.position.x, -boundaryX, boundaryX); float y = Mathf.Clamp(transform.position.y, -boundaryY, boundaryY); transform.position = new Vector3(x, y, transform.position.z); }

smoothTime 参数直接决定手感,0.1 到 0.15 是最跟手的区间,0.3 以上会有明显的迟滞感,适合做宇宙飞船大质量漂移。注意 SmoothDamp 的 velocityRef 是需要跨帧保持的,千万别在 Update 里 new 一个新的 Vector3,否则每次都会重置缓冲,手感立刻变回冰面。

如果你的期末答辩需要展示“我做了数值调优”,这个参数就是绝佳的素材:把 smoothTime 从 0.3 一路调到 0.1,让台下老师看着飞机从漂移感变成跟手感,比嘴上说“我调过手感”有力得多。

3.3 相机跟随:用 LateUpdate 干掉抖动

相机跟随挂在 Update 里会导致一个经典问题:相机和飞机的执行顺序不确定,偶尔出现飞机已经移动但相机还停在上一帧位置,画面就会一卡一卡。正确做法是把跟随逻辑放在 LateUpdate 中,它会在所有 Update 执行完之后再跑一次,保证相机看到的是飞机最新位置。

using UnityEngine; public class CameraFollow : MonoBehaviour { public Transform target; // 要跟随的飞机 public Vector3 offset = new Vector3(0f, 2.5f, -8f); // 相机相对飞机的偏移 public float smoothTime = 0.2f; // 跟随缓冲时间 private Vector3 velocityRef = Vector3.zero; void LateUpdate() { Vector3 desiredPosition = target.position + offset; transform.position = Vector3.SmoothDamp( transform.position, desiredPosition, ref velocityRef, smoothTime); transform.LookAt(target); } }

offset 的 z 轴用负值是因为 Unity 左手坐标系下,相机的正前方是 z 轴正方向,飞机在原点时,相机放 z = -8 才能照到飞机。smoothTime 建议设 0.15 到 0.25,太大会让镜头像绑了橡皮筋,太小则失去了追尾镜头应有的缓冲。注意目标飞机如果做了爆炸销毁,销毁代码里要判断相机 target 不为空,否则下一帧 LateUpdate 会直接报空引用。

4. 射击、敌机与爆炸特效:把“能打”变成“能看”

4.1 子弹用对象池还是 Instantiate:课堂项目我这样选

网上教程都推荐对象池,理由是避免频繁实例化造成的卡顿。但我的建议是:如果你的子弹同时在场数量不超过 30 发,直接Instantiate完全够用,期末作业演示也不会掉帧。对象池能复用的前提是你反复创建和销毁同样的对象,而太空射击游戏的子弹生命周期极短,当场生成当场销毁,池子反而多了一套“取对象、放对象、清状态”的逻辑,复杂度上去了,展示效果并没有变好。

真正需要对象池的信号是:子弹携带复杂材质或需要预热的粒子系统,创建瞬间有明显掉帧。如果只是普通白色球体加一个点光源特效,Instantiate就够了。我把子弹脚本写成这样:

using UnityEngine; public class Bullet : MonoBehaviour { public float speed = 25f; // 子弹飞行速度 public float lifeTime = 2f; // 存活时间,超时自动销毁避免泄漏 public int damage = 1; // 对敌机造成的伤害 void Start() { // 超时销毁,防止子弹飞出边界后永不清理 Destroy(gameObject, lifeTime); } void Update() { // 沿自身 z 轴正方向飞行 transform.position += transform.forward * speed * Time.deltaTime; } void OnTriggerEnter(Collider other) { // 只对带 EnemyTag 的对象生效 if (other.CompareTag("Enemy")) { Destroy(other.gameObject); // 真实项目中这里应该扣血 Destroy(gameObject); // 子弹命中后消失 } } }

speed 设 25 到 30 肉眼感觉最舒服,太慢子弹显得发飘,太快则几乎没有飞行过程,看不出是射击。damage 字段在这个版本里只做占位,后面接入敌机血量系统时直接用。lifeTime 设 2 秒,对应最远射程约 50 到 60 个单位,边界外的敌机打不到也正常。

4.2 敌机生成器:波次、数量与难度曲线的写法

敌机生成器我用协程写最省事。协程可以暂停继续,天然适合做间隔生成。难度曲线不必复杂,核心规则就是“存活敌机数量上限随当前击毁数缓慢增加”。这样既不会前期一堆敌人压过来打不完,也不会后期没有压力。

using System.Collections; using UnityEngine; public class EnemySpawner : MonoBehaviour { public GameObject enemyPrefab; // 敌机预制体 public float spawnInterval = 1.2f; // 生成间隔,秒 public int baseMaxEnemies = 3; // 初始最大存活数 public int enemiesKilled = 0; // 累计击毁数,用于提升难度 private int aliveEnemies = 0; // 当前存活数 void Start() { StartCoroutine(SpawnLoop()); } IEnumerator SpawnLoop() { while (true) { // 每轮根据击毁数动态计算最大存活数,上限设 10 防止失控 int maxEnemies = Mathf.Min(baseMaxEnemies + enemiesKilled / 3, 10); if (aliveEnemies < maxEnemies) { SpawnOneEnemy(); } yield return new WaitForSeconds(spawnInterval); } } void SpawnOneEnemy() { // 在屏幕上方随机 x 坐标生成,y 固定在场景顶部 float x = Random.Range(-8f, 8f); Vector3 pos = new Vector3(x, transform.position.y, transform.position.z); GameObject enemy = Instantiate(enemyPrefab, pos, Quaternion.identity); aliveEnemies++; } // 由敌机销毁时调用,注意用公共方法而不是事件,简单直接 public void OnEnemyDestroyed() { aliveEnemies--; enemiesKilled++; } }

难点在于“敌机销毁时调用OnEnemyDestroyed”。最简单的办法是在敌机脚本的 OnTriggerEnter 里找到场景中的生成器再调用,性能略差但在课堂项目里无所谓。更稳的做法是敌机挂载的时候直接把生成器传入敌机脚本,销毁时直接调引用。我最常用后者,避免依赖FindObjectOfType。

4.3 特效与音效:用引擎自带对象省下两百行

爆炸特效不用找素材包,Unity 自带的粒子系统足够。新建一个空对象挂上 ParticleSystem,选一颗球形粒子,Start Lifetime 设 1 到 1.5 秒,Start Speed 设 0,Start Size 设 1,把 Shape 设成 Sphere,Emission Rate 设 30 到 50,这样一个简单爆炸云就出来了。代码里在敌机销毁时生成特效对象,然后让特效自毁:

public GameObject explosionPrefab; // 爆炸特效预制体 public void OnTriggerEnter(Collider other) { if (other.CompareTag("Bullet")) { Instantiate(explosionPrefab, transform.position, Quaternion.identity); Destroy(gameObject); } }

特效预制体记得让自身的 ParticleSystem 勾选“Play On Awake”,这样生成后不用额外调用。音效同理,用一个 AudioSource 挂在特效物体上,启动时播放一段爆炸音效,配合自动销毁即可。别把音效做成常驻组件,否则屏幕外敌机爆炸也会“啪”地响一声,观众会觉得很奇怪。

4.4 UI 计分与 Game Over 判定:给演示留个体面收尾

计分不复杂,用静态变量最简单。创建 UI Canvas,加一个 Text 显示分数。敌机销毁时加分,玩家销毁时扣一条命,演示时至少要有 Game Over 状态,否则演示到一半飞机死了画面就僵在那里。用静态变量省去了各种获取引用的麻烦,但对多场景项目要记得在开始时重置,否则第二次进入游戏分数会继承上一次的结果。

我的计分核心逻辑是这样:

public class GameState : MonoBehaviour { public static int score = 0; // 总分 public static int lives = 3; // 剩余命数 public Text scoreText; // 拖拽到 UI 面板 public static void AddScore(int point) { score += point; } public static bool LoseLife() { lives--; if (lives <= 0) { Time.timeScale = 0f; // 暂停游戏,并显示 Game Over 面板 return true; } return false; } }

在敌人脚本的 OnTriggerEnter 里调用GameState.AddScore(10)就完成了加分。Game Over 时Time.timeScale = 0这招最省事,全场物理和输入全部暂停,但 UI 动画还能继续播放。注意如果你用了协程,协程里的WaitForSeconds会被 timeScale 影响,所以要给 WaitForSeconds 传true作为第二参数,否则协程也会被暂停。

5. 期末作业五大常见坑:从穿模毁档到打包黑屏

5.1 NullReferenceException:代码挂错对象比逻辑错更常见

现象:一运行控制台立刻爆红,通常是 Object reference not set to an instance of an object。最典型的场景是脚本挂在普通 GameObject 上,但bulletPrefab和firePoint忘了从 Inspector 拖拽赋值,代码在按键时才访问,按键瞬间才报错。

原因:Inspector 里的公共变量没有赋值,或者赋值了但在 Awake 被覆盖。很多同学会在 Start 里写GetComponent,却没考虑脚本在场景加载时先于组件赋值执行。

解决:养成 Awake 里做判空检查的习惯,并保证所有公共引用先拖拽赋值再运行。

void Awake() { if (bulletPrefab == null) Debug.LogError("bulletPrefab 未赋值,请拖拽子弹预制体到 Inspector"); if (firePoint == null) Debug.LogError("firePoint 未赋值,请把机头位置的空物体拖拽过来"); }

这条检查能省下答辩前最后十分钟的紧张局面。别问我是怎么知道的,我见过某学弟演示前两分钟控制台刷了一屏红,最后才发现脚本挂错了对象。

5.2 飞机被自己的子弹打死:碰撞矩阵没设好

现象:开火后飞机血条疯狂往下掉,第一发子弹出去自己先炸了。

原因:默认碰撞矩阵里所有层都互相碰撞。子弹的 OnTriggerEnter 判断的是 EnemyTag 没错,但如果玩家身上也误挂了 EnemyTag,或者玩家碰撞体比视觉模型大了一圈,子弹刚出膛就撞到了玩家自己的碰撞体。

解决:第一步先按 2.2 把 Bullet 层级和碰撞矩阵独立出来,让子弹只碰撞 Enemy 层。第二步检查玩家身上是否误挂 EnemyTag,这一步在演示项目里最容易被忽略,因为复制敌机预制体改造成玩家时,Tag 往往没改。第三步在子弹代码里加一层保险:

if (other.CompareTag("Enemy") == false) return;

这行代码码率高一点,但能挡住奇奇怪怪的误伤情况。

5.3 相机穿模与抖动:把 Main Camera 抓进玩家子物体

现象:相机过一会儿就卡进飞机内部,或者镜头剧烈抖动,看起来像空间站没对接上。

原因:相机的 target 指向飞机模型的一个子物体,而这个子物体在飞行过程中发生旋转或位置偏移,导致 offset 计算错误。另一个常见原因是相机刚体或碰撞体没移除,相机和飞机发生物理挤压。

解决:检查相机物体上有没有多余的 Collider 和 Rigidbody,有就删掉,相机只负责渲染,不该参与物理。如果抖动只出现在高速飞行时,把相机跟随的 smoothTime 从 0.2 调到 0.1,并且确认移动用的是 SmoothDamp 而不是直接赋值,直接赋值会导致镜头硬跟着目标跳,看起来就像抖动。

5.4 打包后没声音或没贴图:场景没加进 Build 列表

现象:编辑器里一切正常,导出后飞机是紫色、场景一片黑、音效全无。这三个问题各有各的原因,但最常被忽略的只有一个。

原因:Build Settings 里的 Scenes in Build 列表遗漏了当前场景。编辑器测试时会自动加载当前场景,打包后只加载列表里勾选的场景。漏掉场景的表现不是报错,而是整个游戏画面黑屏或空场景。

解决:打开 File -> Build Settings,把当前场景拖进 Scenes in Build,并确保列表第一个场景是游戏初始场景。音效没有则检查 AudioSource 是否设成 Play On Awake,以及音频文件是否在 Audio 文件夹内被引擎识别。贴图发紫是材质球丢 Shader 或贴图丢失,重新拖拽一遍贴图到对应槽位通常就好了。

5.5 场景整体发黑:没烘焙灯光或者后处理没关

现象:场景里明明放了好几个平行光和点光源,运行后仍然黑到看不见飞机。

原因:Unity 的灯光在默认渲染管线下没问题,但如果你用了 URP 或 HDRP,场景灯光需要重新配置。另一个常见原因是创建场景时选择的是空 3D 场景,默认 Skybox 是黑的,背景一片黑,玩家下意识以为整个场景坏了。

解决:在 Window -> Rendering -> Lighting 里把 Skybox Material 设回默认,或者在相机上把 Clear Flags 设为 Solid Color,背景选深蓝色。URP 用户要注意在 Render Pipeline Asset 里启用 Main Light 的阴影,否则方向光不生效。做完这步如果还是黑的,去检查平行光旋转角度是不是朝上照,初级项目里 90% 的“整场全黑”都是平行光角度不对。

6. 交付前十分钟自测:检查清单和一键重置的小技巧

交作业前十分钟我习惯按固定顺序自查一遍,避免演示现场翻车。先打开 Build Settings 确认当前场景在列表里,再运行游戏,按顺序检查:一、飞机上下左右移动是否跟手,按方向键时镜头有没有抖动;二、空格键发射子弹,子弹是否能正常飞向屏幕中央,命中敌机时有没有爆炸特效和加分;三、连续几轮敌机生成后,数量是否还在合理范围内,会不会一次刷出二十个敌人;四、故意让飞机被撞一次,确认掉命逻辑生效,Game Over 面板能弹出来;五、暂停游戏检查 UI 分数和飞行状态是否被冻结。全过一遍只需要三分钟,能拦下 90% 的低级事故。

一个很实用的小技巧是给游戏做“一键重置”,方便演示失败后快速恢复现场。不用跑回编辑器按停止再重新运行,直接在游戏里按 R 键重新加载当前场景即可:

using UnityEngine; using UnityEngine.SceneManagement; public class ResetGame : MonoBehaviour { void Update() { if (Input.GetKeyDown(KeyCode.R)) { // 重置计分和命数,再重载场景 GameState.score = 0; GameState.lives = 3; Time.timeScale = 1f; SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex); } } }

有些同学会在 Game Over 界面卡住,因为 timeScale 被设成 0 后所有 Update 都停了,重置脚本也失效。所以我习惯把重置脚本里的输入判断放在Input.GetKeyDown之前加上Time.timeScale = 1f,并且把重置脚本挂在一个不随游戏暂停的 UI 对象上。另一个替代方案是给相机挂脚本,因为相机接收输入一般不受 timeScale 影响。

最后说一个我这两年养成的习惯:每写完一个系统,就顺手在 Inspector 里把所有公共变量改成有意义的默认值,再写一行判空。这比任何注释都管用,因为期末作业阶段你不会记得三天前那个speed到底是 15 还是 50,更不会记得自己有没有把bulletPrefab拖上去。项目越大,这个习惯越能省命。这套流程我从头到尾写完一个可玩的太空飞机射击项目不到四个小时,按这个顺序来,你也能赶上截止时间。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询