简介:这是一份面向Unity 3D课程设计与期末大作业的飞机大战游戏完整项目,开发语言为C#,适合需要完成高分课设或希望从零上手Unity游戏开发的学生参考。项目基于Unity引擎组织工程资源,共约2000个文件,包含场景、预制体、C#脚本、动画剪辑、三维模型、材质贴图、着色器、音频字体等主要类型,并附有PDF/Word使用文档,压缩包约649.9MB,下载后可直接运行。代码注释详细,能帮助初学者理解游戏循环、碰撞检测、敌机生成、计分与UI管理等核心模块;同时角色动画与枪械资源齐全,整个项目具备从玩家操控、敌机波次生成、碰撞判定到得分展示的完整游戏流程,项目结构清晰,学有余力者可在此基础上扩展功能或替换美术资源,进行二次开发。配套使用文档包含运行环境、场景结构、核心代码解析等内容,方便对照学习。项目整体达到97分高分设计水准,现有501人学习下载,适合需要完整可演示高分项目、快速搭建期末作业框架的读者作为实用参考,尤其适合答辩或期末提交前需要稳定可运行项目的情况。
1. 飞机大战是Unity期末作业里最容易“做完却拿不到90分”的项目
几乎所有Unity初学者都会在期末选择飞机大战,因为玩法固定、素材好找、逻辑直观。但正因如此,老师对这类项目的评分标准会格外苛刻:界面是否美观、代码是否规范、是否用了对象池、是否有完整的UI状态切换、音效与粒子是否到位、打包后是否能在不同分辨率和平台上稳定运行。很多人的作业停留在“飞机能飞、子弹能打、敌人会撞”的层面,代码全塞在一个Update里,UI只有两个按钮,这样的项目通常只能拿70到80分。真正能上95分的飞机大战,拼的不是玩法创新,而是工程组织能力——把输入、对象池、碰撞、UI状态机、特效、音频、文档这些环节用合理的方式串起来。这篇博客就按我平时带项目验收的思路,把这个过程完整拆开,每一步都给出可复现的代码、参数和检查点,适合你直接照着自己做一份高完成度的期末作业。
2. 核心循环与对象池:让子弹和敌机不再频繁Instantiate
飞机大战的卡顿根源往往不是模型面数,而是每帧创建和销毁GameObject。默认的Instantiate和Destroy会触发GC分配、以及生命周期回调,弹幕一多就开始掉帧。正确做法是预创建足够数量的实例,运行时只激活和休眠。
2.1 玩家飞机的输入与边界约束
玩家控制常见有两种方案:鼠标跟随和键盘方向键。期末作业建议两者都做,鼠标控制适合演示,键盘控制适合可靠操作。输入读取放在Update里,物理移动放在FixedUpdate里,这里有个容易踩的坑。
public class PlayerController : MonoBehaviour { public float moveSpeed = 12f; public Vector2 xRange = new Vector2(-7f, 7f); public Vector2 yRange = new Vector2(-4f, 4f); private Vector2 moveInput; private Rigidbody2D rb; void Start() { rb = GetComponent<Rigidbody2D>(); } void Update() { float h = Input.GetAxisRaw("Horizontal"); float v = Input.GetAxisRaw("Vertical"); if (Input.GetMouseButton(0)) { Vector2 mousePos = Camera.main.ScreenToWorldPoint(Input.mousePosition); moveInput = (mousePos - (Vector2)transform.position).normalized; } else { moveInput = new Vector2(h, v); } } void FixedUpdate() { Vector2 next = rb.position + moveInput * moveSpeed * Time.deltaTime; next.x = Mathf.Clamp(next.x, xRange.x, xRange.y); next.y = Mathf.Clamp(next.y, yRange.x, yRange.y); rb.MovePosition(next); } }逻辑说明:把输入解析和物理移动拆到两个函数中,Update只负责获取方向,FixedUpdate负责实际位移,避免帧率波动影响移动速度。GetAxisRaw比GetAxis更适合做精确移动,因为它是离散的-1/0/1,不会带平滑过渡。屏幕到世界坐标的转换用的是主摄像机的ScreenToWorldPoint,需要注意把Z轴设为摄像机距离,否则可能得到近裁剪面的坐标。Clamp把飞机限制在预设的边界矩形内,这比直接限制Transform更稳定,因为刚体移动会绕过一些碰撞检测。
2.1.1 为什么用Rigidbody2D而不是直接改Transform
直接改Transform在无碰撞需求时更简单,但飞机大战里后续要加撞击伤害、被击退、爆炸后停止运动等状态,用Rigidbody2D的MovePosition可以保证物体不会穿透静态碰撞体,而且将来接OnCollisionEnter2D不需要额外设置。如果你用Transform移动同时挂Collider,快速移动时容易发生隧穿。
2.2 对象池设计:同时容纳200发子弹也不产生GC
子弹、敌机、爆炸特效都要进对象池。推荐一种不依赖Unity生命周期的最简实现:核心是栈存储,压入时SetActive(false),弹出时SetActive(true)并重置数据。
public class SimplePool { private Stack<GameObject> pooled; private GameObject prefab; private Transform parent; public SimplePool(GameObject prefab, int preloadCount, Transform parent) { this.prefab = prefab; this.parent = parent; pooled = new Stack<GameObject>(); for (int i = 0; i < preloadCount; i++) { GameObject go = Object.Instantiate(prefab, parent); go.SetActive(false); pooled.Push(go); } } public GameObject Get() { if (pooled.Count == 0) pooled.Push(Object.Instantiate(prefab, parent)); GameObject go = pooled.Pop(); go.SetActive(true); return go; } public void Release(GameObject go) { go.SetActive(false); pooled.Push(go); } }逻辑说明:预加载数量通常设为屏幕同时出现对象峰值的1.2倍。比如同时最多20架敌机、50发子弹,那就预加载24架敌机和60发子弹。池内没有对象时再Instantiate,后续复用不再分配内存。Release由对象自身在生命周期结束时调用,比如子弹飞出屏幕或碰到敌机,不是用Destroy,而是调用Release。
2.2.1 子弹自回收的两种触发方式
子弹脚本里建议同时写两个回收条件:超出屏幕边界或命中目标。用事件委托把回收逻辑发给池,比让子弹直接引用池实例更解耦。
public class Bullet : MonoBehaviour { public System.Action<GameObject> OnReturn; public float lifetime = 3f; private float elapsed; void OnEnable() { elapsed = 0f; } void Update() { elapsed += Time.deltaTime; if (elapsed >= lifetime) { OnReturn?.Invoke(gameObject); } } private void OnTriggerEnter2D(Collider2D other) { if (other.CompareTag("Enemy")) { OnReturn?.Invoke(gameObject); } } }注意这里的OnEnable重置计时器,否则从池里拿出来的子弹会残留上一次的存活时间。lifetime参数需要根据屏幕尺寸和子弹速度调整,我通常设成这样:如果子弹每帧移动speed = 20,屏幕高度为Camera.main.orthographicSize * 2,那么lifetime = 屏幕高度 / speed + 0.5f,多出的0.5秒作为边界缓冲。
2.3 碰撞检测从触发器换成自定义区域查询
当子弹和敌机用OnTriggerEnter2D直接判断时,一个子弹要跟所有敌机做碰撞检测,复杂度是O(N×M)。当屏幕单位超过200个时,物理引擎开销会明显上涨。常见做法是用空间哈希或简单的分格检测。不过对于期末项目,你不需要实现完整四叉树,可以用一个更轻的方案:把敌机按X坐标分桶,子弹只检查邻近桶中的敌机。
public class SpatialBucket : MonoBehaviour { public float bucketWidth = 2f; private Dictionary<int, List<Enemy>> buckets = new Dictionary<int, List<Enemy>>(); public int GetBucket(float x) { return Mathf.FloorToInt(x / bucketWidth); } public void RegisterEnemy(Enemy enemy) { int b = GetBucket(enemy.transform.position.x); if (!buckets.TryGetValue(b, out List<Enemy> list)) { list = new List<Enemy>(); buckets[b] = list; } list.Add(enemy); } public List<Enemy> GetNearby(Vector2 pos, float range) { int minB = GetBucket(pos.x - range); int maxB = GetBucket(pos.x + range); List<Enemy> result = new List<Enemy>(); for (int b = minB; b <= maxB; b++) { if (buckets.TryGetValue(b, out List<Enemy> list)) result.AddRange(list); } return result; } }用法是:敌机生成时注册到对应分桶,子弹每帧调用GetNearby拿候选列表,再逐个做距离判断。这个方案比物理碰撞快很多,而且代码量小,还能在UI上画出Debug信息,演示“我做了空间优化”,这是加分项。要注意敌机移动后需要更新分桶,通常做法是在敌机Update末尾检查是不是已经进到新桶,是则移除旧桶注册。这块逻辑如果写不好容易出错,建议直接让敌机持有当前的桶ID。
3. UGUI界面状态机:开始、暂停、结算与计分不再是一团糟
很多作业只有“开始游戏”和“退出”两个按钮,暂停直接拖时间缩放,结算用Debug.Log。这样的项目即便玩法完整,老师也会在工程结构上扣分。用状态机管理UI面板是非常基础且必要的。
3.1 用Canvas Group和Enum切换面板
创建一个UIManager,持有所有面板的引用,每帧只激活一个面板。面板切换用淡入淡出而不是直接SetActive,这个细节能提升完成度。
public enum UIState { MainMenu, Playing, Paused, GameOver } public class UIManager : MonoBehaviour { public CanvasGroup mainMenuPanel; public CanvasGroup playingPanel; public CanvasGroup pausedPanel; public CanvasGroup gameOverPanel; public float fadeDuration = 0.3f; public GameObject scoreText; private UIState currentState; private Coroutine fadeRoutine; public void SwitchTo(UIState newState) { if (fadeRoutine != null) StopCoroutine(fadeRoutine); CanvasGroup target = GetPanel(newState); CanvasGroup previous = GetPanel(currentState); fadeRoutine = StartCoroutine(FadeGroup(previous, target)); currentState = newState; } private IEnumerator FadeGroup(CanvasGroup from, CanvasGroup to) { if (from != null) { from.interactable = false; while (from.alpha > 0) { from.alpha -= Time.deltaTime / fadeDuration; yield return null; } from.gameObject.SetActive(false); } to.gameObject.SetActive(true); to.interactable = true; while (to.alpha < 1) { to.alpha += Time.deltaTime / fadeDuration; yield return null; } } private CanvasGroup GetPanel(UIState state) { switch (state) { case UIState.MainMenu: return mainMenuPanel; case UIState.Playing: return playingPanel; case UIState.Paused: return pausedPanel; case UIState.GameOver: return gameOverPanel; default: return null; } } }逻辑说明:CanvasGroup的alpha、interactable、blocksRaycasts三个属性是面板控制的标配。这里让切换的协程在结束前把旧面板禁用,避免隐藏面板上的按钮仍然响应点击。currentState初始值要设为MainMenu,在Start里调用SwitchTo前把所有面板alpha设为0或直接禁用,否则会出现闪烁。
3.1.1 暂停时别用Time.timeScale = 0一劳永逸
正确的暂停做法是冻结所有游戏逻辑,但同时让UI的动画和音效维持。如果直接Time.timeScale = 0,暂停面板的Fade协程也会被冻结,因为协程依赖WaitForSeconds。解决方式:用WaitForSecondsRealtime让协程不受timeScale影响,或者在暂停状态下直接瞬间切到暂停面板而不是淡入。我一般选择后者:暂停时界面快速切换,不用动画。
3.2 计分系统用单例加事件,而不是到处查找
计分值需要被UI文本框、结算面板、排行榜(如果有)共同访问。用全局单例ScoreManager保存分数,提供AddScore方法,并在变化时触发事件。
public class ScoreManager : MonoBehaviour { public static ScoreManager Instance { get; private set; } public int CurrentScore { get; private set; } public event System.Action<int> OnScoreChanged; private void Awake() { if (Instance != null && Instance != this) { Destroy(gameObject); return; } Instance = this; DontDestroyOnLoad(gameObject); } public void AddScore(int value) { CurrentScore += value; OnScoreChanged?.Invoke(CurrentScore); } public void ResetScore() { CurrentScore = 0; OnScoreChanged?.Invoke(CurrentScore); } }逻辑说明:DontDestroyOnLoad使得切场景时分数不重置。UI面板在Start中订阅事件,在OnDestroy中退订,防止内存泄漏。用事件让计分显示和游戏逻辑解耦,后续如果增加连击判定,只需在连击逻辑里调用AddScore乘数即可,不需要改UI代码。
3.2.1 分数文本的本地格式
在UI文本上显示分数时,注意用字符串拼接会频繁产生GC,建议用IntToString缓存,或者使用ZString库。但对于期末作业,简单做法是:
scoreText.text = "分数:" + ScoreManager.Instance.CurrentScore.ToString();如果觉得演示时有不流畅,可以用StringBuilder每帧clear一次。实际上Text组件重构文字本身是主要开销,建议只在OnScoreChanged事件中更新,而不是每帧刷新。
3.3 音效和特效的挂载方式
飞机大战需要射击音效、爆炸音效、背景音乐。为了避免每个敌机都挂一个AudioSource,用一个全局AudioManager单脚本来播放,把所有音效clip做成数组,用枚举索引。
public class AudioManager : MonoBehaviour { public static AudioManager Instance; public AudioClip[] clips; private AudioSource sfxSource; private AudioSource musicSource; public void PlaySfx(int clipIndex, float pitchVariation = 0.05f) { sfxSource.pitch = Random.Range(1 - pitchVariation, 1 + pitchVariation); sfxSource.PlayOneShot(clips[clipIndex]); PlayerPrefs.SetFloat("sfxVolume", sfxSource.volume); } }注意PlayOneShot是叠加播放的,不会打断前一个同音效,适合密集射击。pitchVariation是为了避免多次播放同一音效听起来像机械重复,这是游戏音频设计中很便宜但很有效的手段。
4. 让游戏看起来像95分:PBR材质、粒子拖尾、屏幕震动与后处理
这一章解决“看起来”的问题。飞机大战的模型往往是自己用Cube或Sprite拼的,如果直接用默认材质,画面非常廉价。通过参数调整和免费插件,可以让画面提升一个档次。
4.1 给简易战机模型升级材质
如果战机模型是用Blender做的低多边形,用Unity内置Standard材质时要注意金属度、光滑度、自发光三个参数。金属度过高会导致环境反射过强,灯光不足时发黑。推荐设置如下表:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| Metallic | 0.1~0.3 | 模拟机身漆面,过高会失去细节 |
| Smoothness | 0.4~0.7 | 控制高光锐度 |
| Emission | Color 1.2~2.0 | 引擎喷口、驾驶舱发光 |
| Albedo | 不超过0.8灰度 | 防止白色过曝 |
材质上可以给飞机加一个MaterialPropertyBlock,在不打断合批的情况下换颜色或发光强度。但这里有个误区:不要每个升级都生成新材质实例,要用PropertyBlock。
var block = new MaterialPropertyBlock(); renderer.GetPropertyBlock(block); block.SetColor("_EmissionColor", Color.red); renderer.SetPropertyBlock(block);4.2 粒子系统做爆炸和拖尾
爆炸用ParticleSystem的Burst配合子发射器实现。核心参数:Simulation Space 设为 World,这样飞机移动时粒子留在原地;Start Speed 控制飞散范围;Start Lifetime 控制烟雾持续时间。拖尾最简单的是给子弹挂一个TrailRenderer,把Time设为 0.3,MinVertexDistance设为 0.1,这样不会因为子弹速度太快产生断点。
一个经验:爆炸粒子的颜色曲线在ColorOverLifetime中设置从黄到红再到黑,速度快一点,比用单一颜色真实得多。别忘了在Renderer模块中把Material换成背景透明的粒子材质,否则会看到方形黑底。
4.2.1 对象池和粒子系统的回收配合
粒子系统播放完毕后不会自动销毁。可以在粒子脚本里监听主模块的stopAction,设置为Callback,然后调用池回收:
public class ExplosionEffect : MonoBehaviour { public System.Action<GameObject> OnFinished; public void OnParticleSystemStopped() { OnFinished?.Invoke(gameObject); } }需要注意ParticleSystem.Stop(false, ParticleSystemStopBehavior.StopEmittingAndClear)后也会触发OnParticleSystemStopped,所以回收条件要判断是否是持久状态。
4.3 别忽视Cinemachine和Post Processing
这两个包都可以在Unity Package Manager中免费安装。Cinemachine用于实现屏幕震动和镜头跟随,虽然飞机大战通常是固定视角,但震动效果非常提升爽快感。
public class ShakeOnExplosion : MonoBehaviour { public CinemachineVirtualCamera vcam; public float shakeIntensity = 2f; public float shakeTime = 0.2f; void Start() { var noise = vcam.GetCinemachineComponent<CinemachineBasicMultiChannelPerlin>(); noise.m_AmplitudeGain = shakeIntensity; noise.m_FrequencyGain = 1.5f; Invoke(nameof(StopShake), shakeTime); } void StopShake() { var noise = vcam.GetCinemachineComponent<CinemachineBasicMultiChannelPerlin>(); noise.m_AmplitudeGain = 0f; } }Post Processing里最值得调的是Bloom和Vignette。Bloom可以让子弹发光的感觉更强烈,但注意不要开太高,Intensity0.3到0.6即可,否则整个画面泛白。Vignette默认值即可,增加视觉焦点。
5. 写一份让老师愿意细看的使用文档与打包验收清单
很多作业的README只有“鼠标移动,左键射击”一行字。但其实使用文档是评分中的重要部分,尤其在“源码+使用文档”被明确写在标题里时,文档质量直接影响最终分数。
5.1 使用文档必须包含的六个章节
| 章节 | 内容要点 |
|---|---|
| 项目介绍 | 游戏玩法、操作方式、得分条件 |
| 运行环境 | Unity版本、目标平台(Windows/Mac)、屏幕分辨率要求 |
| 操作说明 | 每个按键对应的功能,注意写清楚手柄/键盘/鼠标三种方式 |
| 源码结构 | 每个核心脚本的作用,以及它们之间的关系 |
| 自定义修改 | 如何改飞机速度、子弹伤害、敌机生成频率等参数 |
| 常见问题 | 打包后无法运行、中文路径报错、字体缺失等 |
每个章节不用长,但要把关键参数表列出来。比如在“自定义修改”里列一个参数表:
| 参数位置 | 参数名 | 默认值 | 作用 |
|---|---|---|---|
| PlayerController | moveSpeed | 12 | 玩家飞机移动速度 |
| EnemySpawner | spawnInterval | 1.0 | 敌机生成间隔 |
| Bullet | damage | 1 | 子弹对敌机造成的伤害 |
5.2 构建时最容易翻车的四个问题
第一,项目内所有素材路径不能包含中文字符和特殊符号,否则打包后在非中文系统上可能加载失败。第二,Build Settings里要选PC, Mac & Linux Standalone,然后在Player Settings中设置Default Icon和Resolution,勾选Fullscreen Window而不是Exclusive Fullscreen,方便演示时随时切窗口。第三,如果用了DontDestroyOnLoad,要确认场景切换后UI不重复生成,否则会出现多个计分管理器。第四,打包后字体丢失往往是因为动态字体没有包含在构建中,在Font的高级设置里勾选Include Font Data。
5.3 30分钟全功能自测表
这一节值得写进文档的结尾,也能用来自己检查。按这个顺序过一遍,问题会在验收前暴露:
| 检查项 | 操作 | 预期结果 |
|---|---|---|
| 开始游戏 | 点击主菜单按钮 | 切换到游戏画面,背景音乐开始 |
| 键盘移动 | WASD | 飞机按相应方向移动,且不越界 |
| 鼠标控制 | 按住鼠标左键拖动 | 飞机跟随鼠标移动,且相对平滑 |
| 发射子弹 | 空格或左键 | 子弹从机头前方生成,无卡顿 |
| 击毁敌机 | 让子弹命中敌机 | 敌机爆炸粒子播放,分数增加 |
| 暂停功能 | 按ESC或点击暂停 | 暂停面板显示,时间冻结 |
| 重新开始 | 在结算面板重新开始 | 回到开始界面,分数清零 |
| 打包运行 | 双击exe | 帧率稳定,无报错,背景音乐正常 |
自测表本身也是文档的加分项,它能证明你做了系统性的验证。最后再强调一个细节:把Unity版本号和编辑器版本号写在文档里,因为很多老师打开项目时会因为版本不匹配而无法运行,这也会影响评分。文档结尾留一页写“开发环境与贡献者信息”就足够了。
本文还有配套的精品资源,点击获取