简介:这是一份基于 Unity 3D 实现的太空飞机射击游戏期末作业项目,面向正在学习 Unity 或需要完成同类课程设计的学生。工程覆盖了游戏开发的主要环节:太空场景搭建、飞机移动控制、子弹射击与对象池、碰撞检测、分数与游戏状态管理,以及音效和 UI 界面,可直接在 Unity 中打开查看完整实现。
压缩包共包含 11022 个文件,大小约 87.43MB。其中 C# 脚本(1413 个)是游戏逻辑核心,meta 与 info 文件用于 Unity 资源管理与导入,png 贴图及 mp3/wav 音频负责视觉与音效表现,prefab 预设和 asset 场景文件则组织着游戏对象和场景结构,适合作为期末项目参考或学习 Unity 的完整案例。
目前已有 1991 人学习下载。通过这套工程,读者能系统掌握 Unity 的脚本编写、组件协作、对象池优化及项目组织方法,还可在现有代码基础上继续扩展功能,是快速理解 Unity 游戏开发流程的实用资料。
1. 太空飞机射击游戏的期末作业,为什么值得用 Unity 认认真真做完
又到期末,很多同学拿到“太空飞机射击游戏”这种经典题目时,第一反应是找个现成模板改一改。但做过的人都知道,模板 Demo 交上去,答辩时老师问两个问题就露馅了:碰撞体为什么这么设?对象池解决了什么?所以这篇笔记不打算给你抄一份完整代码,而是把这类 Unity 2D/3D 射击游戏从场景搭建、发射逻辑、敌机生成到 UI 和打包的完整链路拆开讲,每一步都给出能直接复现的代码、参数和坑位。你要是正在做这门课的期末作业,或者想用一个小项目把 Unity 的核心机制串一遍,这篇应该能省下你不少瞎试的时间。
这个标题看着简单,其实背后覆盖了 Unity 最常考的几个模块:预制体与实例化、刚体与碰撞检测、协程与对象池、UI 事件与场景切换、Build 设置。换句话说,太空飞机射击不是一个“游戏”,而是一套浓缩的 Unity 基本功训练场。我见过不少同学把时间花在找素材、调贴图上,结果核心玩法一周都没跑通,最后熬夜补碰撞逻辑,翻车翻得很惨。下面这套做法,是我自己在多个模拟项目里反复调过、也帮 A 同学和某开发者改过作业后沉淀下来的路径,照着走,至少能在答辩前留出两天做打磨和录屏。
建议你按章节顺序推进,每章结束把代码跑通再进下一章,不要先搭一堆花哨 UI。核心玩法先立住,后面加什么都是加分项。接下来我们先把项目骨架建出来,别急着写代码,先明白每个文件该干嘛。
2. 先搭骨架:场景、脚本和预制体该怎么分层
2.1 场景分层:把“敌机、子弹、玩家”放到三个根节点下
新建一个 3D 项目(如果你是 2D 作业就选 2D 模板,逻辑一样,只是把坐标轴从 XY 换成 XZ 的差异)。很多人一上来就直接往 Hierarchy 里拖模型,不出十个小物体场景就乱成一锅粥。我常用的做法是先建三个空物体:PlayerRoot、BulletRoot、EnemyRoot,把所有玩家、子弹、敌机都挂在对应根节点下。这样做的好处有三个:一是运行时清空子弹只需要遍历一个节点;二是对象池回收时归位方便;三是答辩时老师看 Hierarchy 结构一眼就明白你的思路,印象分会高。
然后是相机和灯光。Main Camera 用正交还是透视取决于你要 2D 表现还是 3D 表现。做太空射击,我建议用透视相机,放到玩家后方偏上,朝向 Z 轴负方向,FOV 设在 60 左右。这样既能看到远处敌机逼近,又有太空纵深。灯光方面给个平行光做主光源,方向稍微倾斜,别让机体的阴影糊成一片。
场景里还要放一个空物体叫 GameManager,挂上你的主控制脚本。这个物体不会在游戏里显示,但它负责全局状态:分数、生命、游戏结束、敌机生成开关。把状态集中在一个管理器里,比让玩家脚本直接访问 UI 要干净得多。后面加暂停、重启都容易。
2.2 脚本分工:别把 800 行代码塞进一个 MonoBehaviour
我收过很多同学的作业,最常见的问题就是所有逻辑都塞在 PlayerController 里:移动、射击、碰撞、得分、生成敌机、更新 UI,全挤在一起。这种代码能跑,但改一个功能就崩三处,答辩时老师一问你某个方法在哪,你自己都找不到。
合理的分工是这样的:
- PlayerController:处理输入、移动、开火
- Bullet:处理子弹飞行、生命周期、碰撞回传
- EnemyController:处理敌机移动、攻击、被击中
- EnemySpawner:控制生成频率和波次
- GameManager:状态流转、分数、UI 刷新
- ObjectPool:管理子弹和敌机的复用
每个脚本职责单一,参数暴露在 Inspector 里,别硬编码。这样你答辩时能说清楚“这个脚本只负责什么”,远比“这个脚本啥都能干”有说服力。下面我们先搭玩家控制器,这是整个项目的输入源头。
2.3 玩家控制器:移动边界和发射点分开配置
玩家控制器的代码逻辑不复杂,但有两个细节经常被忽略:一是移动要限制边界,否则飞机飞出屏幕就回不来了;二是发射点要放在机体前方,不是机体中心。我把发射点做成一个 Transform 数组,在 Inspector 里手动拖两个空物体到机头左右两侧,这样以后想改双发、三发都不用动代码。
using UnityEngine; public class PlayerController : MonoBehaviour { [Header("移动参数")] public float moveSpeed = 12f; public Vector2 boundX = new Vector2(-8f, 8f); public Vector2 boundY = new Vector2(-4f, 4f); [Header("射击参数")] public GameObject bulletPrefab; public Transform[] firePoints; public float fireInterval = 0.15f; private float fireTimer = 0f; void Update() { // 获取输入并移动 float h = Input.GetAxis("Horizontal"); float v = Input.GetAxis("Vertical"); Vector3 move = new Vector3(h, v, 0f) * (moveSpeed * Time.deltaTime); transform.position += move; // 夹紧在边界内 Vector3 pos = transform.position; pos.x = Mathf.Clamp(pos.x, boundX.x, boundX.y); pos.y = Mathf.Clamp(pos.y, boundY.x, boundY.y); transform.position = pos; // 发射倒计时 fireTimer += Time.deltaTime; if (Input.GetButton("Fire1") && fireTimer >= fireInterval) { fireTimer = 0f; Shoot(); } } void Shoot() { foreach (Transform fp in firePoints) { GameObject b = ObjectPool.Instance.GetBullet(); if (b != null) { b.transform.position = fp.position; b.transform.rotation = fp.rotation; } } } }这段代码里,移动用的是Input.GetAxis而不是GetKey,一个很重要的原因是 GetAxis 自带平滑过渡,做飞行手感要比 GetKey 的“开关式”位移好得多,答辩时老师问起来你也能解释清楚。Clamp是 Unity 自带的值夹紧方法,用两个 Vector2 分别存 X 和 Y 的边界,是为了在 Inspector 里直观调整,不重新编译就能改。fireTimer的倒计时方式比直接用InvokeRepeating灵活,应变双发、散射时不用改调用方式。
注意射击这里我用了一个静态的对象池ObjectPool.Instance,还没定义的类也没关系,后面第 4 章会专门讲对象池怎么做、为什么不用Instantiate随手创建子弹。
2.4 预制体:子弹、敌机、爆炸特效分别做成 Prefab
预制体是 Unity 项目里最值得花时间整理的东西。为每种子弹、每种敌机、特效各做一个 Prefab,放到 Assets/Prefabs 下按类型分子文件夹。做 Prefab 时有几个参数我建议提前设好,后面会省很多事。
子弹预制体上需要挂这几个组件:一个Rigidbody并且把isKinematic勾上(子弹的运动用手写代码控制,不靠物理引擎推)、一个SphereCollider并且设成isTrigger、一个Bullet脚本、一个简单的发光材质(自发光用 Emission,不需要额外灯光)。敌机预制体同理,但碰撞体我一般用BoxCollider而不是球体,因为敌机模型通常是长条形的,球体碰撞会让子弹在没碰到机身时就炸,体验很差。
爆炸特效不需要写逻辑,一个ParticleSystem挂上去,在特效预制体里不要勾选Play On Awake,由调用方在炸毁时手动触发。这里有个坑:ParticleSystem 播放一次后对象还活着,如果不做自动销毁处理,特效会积累在场景里。后面对象池一节会给出解决办法。
预制体的一个关键心得:不要在 Inspector 里直接拖模型资源到 Hierarchy 当预制体用,而是先拖进场景调整好位置、缩放、材质,再拖回 Assets 生成 Prefab。这样保证你每次实例化的物体都是调整过的完整状态,而不是原始资源。
3. 子弹逻辑和碰撞处理:让每一发都有据可查
3.1 Bullet 脚本:固定步长移动比 Update 里改位置更稳
子弹移动这段代码看起来简单,但选对移动方式很关键。很多同学在 Update 里直接改transform.position += Vector3.forward * speed * Time.deltaTime,帧率不稳时子弹速度会忽快忽慢。我推荐在FixedUpdate里做位移,因为物理碰撞的检测频率是固定的 50Hz,你在 Update 里移动子弹,碰撞检测会滞后,高速子弹会直接穿过敌机——这就是著名的“子弹穿透”问题。
using UnityEngine; public class Bullet : MonoBehaviour { public float speed = 30f; public float lifeTime = 2f; private float age = 0f; void OnEnable() { age = 0f; } void FixedUpdate() { age += Time.fixedDeltaTime; if (age >= lifeTime) { ObjectPool.Instance.ReleaseBullet(gameObject); return; } transform.position += transform.forward * (speed * Time.fixedDeltaTime); } void OnTriggerEnter(Collider other) { if (other.CompareTag("Enemy")) { EnemyController enemy = other.GetComponent<EnemyController>(); if (enemy != null) { enemy.TakeDamage(1); } ObjectPool.Instance.ReleaseBullet(gameObject); } } }这里有个细节必须说明:子弹本身用了Rigidbody且勾选了isKinematic,那么 OnTriggerEnter 就能正常工作,且不会因为刚体之间的碰撞力互相弹开。如果你忘记勾 isKinematic,子弹会被碰撞反弹,方向就乱了。lifeTime是一个“后悔药”式的参数——子弹最远飞不了多远,超出 2 秒直接回收,防止子弹无限飞行导致场景里挂着大量没用的物体。
OnEnable里重置 age 是配合对象池用的。池里复用的物体会先 SetActive(false) 再 SetActive(true),每次激活都会触发 OnEnable,所以在 OnEnable 里复位属性是正确时机。如果写在 Start 里,第二次出池时 age 还保留着上一次的累计值,子弹就会提前消失。
3.2 碰撞检测三要素:Layer、Tag、Collider 类型缺一不可
碰撞这块是作业里最容易翻车的地方。我总结了一个固定配置流程,你照着做基本不会出问题。
第一,Layer。在 Project Settings → Tags and Layers 里新建两个 Layer:PlayerLayer 和 EnemyLayer。把玩家预制体设成 PlayerLayer,敌机设成 EnemyLayer。GameManager 里用Physics.IgnoreLayerCollision把 PlayerLayer 和 EnemyLayer 设为不碰撞,这样玩家撞到敌机时走触发器逻辑而不是物理弹开。
第二,Tag。给敌机预制体打上 Enemy 标签,子弹打上 Bullet 标签。Tag 用于逻辑判断,Layer 用于物理过滤,两个别混用。你可能会问:既然 Layer 已经区分了敌我,为什么 OnTriggerEnter 里还要用CompareTag("Enemy")?因为 Layer 过滤的是“是否发生物理碰撞”,而 Tag 过滤的是“碰撞后当前脚本要不要处理”。一个触发器可能同时碰到玩家和敌机,你用 Tag 判断能避免子弹打到自己人。
第三,Collider 类型。子弹用 SphereCollider、敌机用 BoxCollider,两者都勾 isTrigger。注意一个方向:触发器碰撞检测要求至少一方带刚体,我们的子弹带 isKinematic 刚体,所以满足条件。如果你发现子弹穿过敌机但没触发,去检查对方是不是没有 Collider,而不是怀疑代码写错了。
这种三个组件协同工作时,答辩时可以顺势讲一句“我把碰撞检测分成了物理层过滤和逻辑层判断两层”,老师一听就知道你理解到位了。
3.3 让子弹命中时有反馈:特效、音效和得分回调
子弹命中后只是消失,玩家没有任何反馈,游戏会显得很干。最简单的做法是在 Bullet 的 OnTriggerEnter 里调用特效和音效管理器。但不要直接在碰撞回调里 Instantiate 特效,因为高频碰撞下会卡顿。
void OnTriggerEnter(Collider other) { if (other.CompareTag("Enemy")) { EnemyController enemy = other.GetComponent<EnemyController>(); if (enemy != null) { enemy.TakeDamage(1); } EffectSpawner.Instance.SpawnHitEffect(transform.position); ObjectPool.Instance.ReleaseBullet(gameObject); } }EffectSpawner是另一个单例管理器,它内部维护一个特效对象池。每次命中时从池里取一个特效,播放完再收回,而不是每次新建。这种做法在高帧率射击游戏里是必须的,因为射击游戏的命中频率非常高,细算下来一分钟可能几百次特效实例化。课后问老师“为什么游戏卡顿”,很多时候就是这个。
音效同理,用一个小脚本管理 AudioSource,命中时随机播放几种爆炸音效中的一种,随机切换音调让音效不那么单调。音效素材不用外找,Unity 自带的 AudioClip 生成工具(Window → Audio → Audio File Generator)就能合成几种简单的激光和爆炸音效,期末作业完全够用。
3.4 激光音效生成:不依赖外部素材也能做出射击反馈
如果你不想去素材站下载音效(很多免费素材有版权问题,答辩时可能被老师追问来源),Unity 自带了一个音频波形生成窗口。打开 Window → Audio → Audio File Generator,可以生成几秒钟的正弦波、方波、噪声等基础音效。用正弦波加指数衰减可以模拟激光声,用噪声加长衰减模拟爆炸。生成后保存到 Assets/Audio 文件夹,拖到 AudioSource 上即可。
这个工具生成的音效质量一般,但作为作业的“音效反馈”已经够了。重要的是它能让你不被素材版权卡住,答辩时也能说明音效来源。后面如果你想做得更精致,可以用开源工具生成更复杂的音效,不过对期末作业来说不是重点。
4. 敌机生成和对象池:高频实例化的性能命门
4.1 对象池原理:为什么 Instantiate 在射击游戏里不能乱用
在很多人的第一版代码里,子弹和敌机都是直接Instantiate动态创建的。这个做法在游戏里物体数量少(比如几十个)时没问题,但射击游戏的问题是——物体生成和销毁的频次极高,一秒钟可能生成几十颗子弹,再加上敌机和特效,每帧都有 Instantiate 和 Destroy 的话,GC 压力会非常大,表现为游戏越玩越卡,最后帧率掉到没法看。
对象池的解决思路很朴素:预先创建一批对象,用的时候激活,用完了“假销毁”(SetActive(false)),下次再用同一批。这样避免了反复的创建和销毁,GC 压力大幅降低。Unity 的 Instantiate/Destroy 并不慢,但频繁调用产生的内存碎片和 GC 是游戏卡顿的主要元凶。
我实现对象池时没用高级插件,用最朴素的 Dictionary + Stack 就够了。核心就两个方法:Get 和 Release,内部按类型区分,维护多个池子。
using System.Collections.Generic; using UnityEngine; public class ObjectPool : MonoBehaviour { public static ObjectPool Instance; [System.Serializable] public class PoolInfo { public string poolName; // 池子标识,比如 "Bullet" public GameObject prefab; public int preloadCount = 20; // 预创建数量 } public PoolInfo[] poolInfos; private Dictionary<string, Stack<GameObject>> pools = new Dictionary<string, Stack<GameObject>>(); private Dictionary<string, GameObject> prefabMap = new Dictionary<string, GameObject>(); void Awake() { Instance = this; InitPools(); } void InitPools() { foreach (PoolInfo info in poolInfos) { prefabMap[info.poolName] = info.prefab; Stack<GameObject> stack = new Stack<GameObject>(); for (int i = 0; i < info.preloadCount; i++) { GameObject obj = Instantiate(info.prefab, transform); obj.SetActive(false); stack.Push(obj); } pools[info.poolName] = stack; } } public GameObject Get(string poolName) { if (!pools.ContainsKey(poolName)) { Debug.LogWarning($"对象池 {poolName} 不存在"); return null; } Stack<GameObject> stack = pools[poolName]; GameObject go; if (stack.Count > 0) { go = stack.Pop(); } else { // 池耗尽时动态扩容 go = Instantiate(prefabMap[poolName], transform); } go.SetActive(true); return go; } public void Release(string poolName, GameObject go) { go.SetActive(false); go.transform.SetParent(transform); pools[poolName].Push(go); } }这个实现有几个要点。第一,预创建数量preloadCount不能拍脑袋设,要在游戏场景里实测峰值数量再加 20% 余量。检查方式是运行时在 Hierarchy 里数 BulletRoot 节点的数量。如果 preload 设大了,启动时白等几秒;设小了,池耗尽时还是会 Instantiate,等于没优化。
第二,Awake里初始化单例,这样任何脚本在Start里调用ObjectPool.Instance都是安全的。用SetParent(transform)把闲置物体会放在对象池管理器节点下,方便调试时一眼看出池里还有多少存货。
第三,Get 返回时 SetActive(true),回收时 SetActive(false)。这个顺序很重要,因为子弹的 OnEnable 会重置状态,你必须保证激活顺序是先复位再显示,否则会出现“新子弹带着旧位置出现”的瞬间闪一下。
4.2 EnemySpawner:用波形控制出怪节奏,而不是纯随机
很多人的敌机生成是InvokeRepeating每 2 秒生成一只,玩起来节奏毫无起伏,答辩时老师问“你游戏后期怎么调难度”,答不上来。我给了一个更实用方案:用一条波形函数控制生成间隔和敌机种类。
using UnityEngine; public class EnemySpawner : MonoBehaviour { public GameObject[] enemyPrefabs; public float baseInterval = 2f; public float minInterval = 0.5f; public float waveDuration = 30f; public float elapsed = 0f; public Transform enemyRoot; private float timer = 0f; void Update() { elapsed += Time.deltaTime; // 随时间推移,生成间隔从 baseInterval 平滑缩到 minInterval float interval = Mathf.Lerp(baseInterval, minInterval, elapsed / waveDuration); timer += Time.deltaTime; if (timer >= interval) { timer = 0f; SpawnEnemy(); } } void SpawnEnemy() { // 权重选择:后期多出小怪,前期少出精英 int index = Random.Range(0, enemyPrefabs.Length); GameObject enemy = ObjectPool.Instance.Get("Enemy"); if (enemy != null) { float x = Random.Range(-8f, 8f); enemy.transform.position = new Vector3(x, transform.position.y, transform.position.z); // 初始化敌机属性(血量、速度) EnemyController ec = enemy.GetComponent<EnemyController>(); ec.InitByWave(elapsed); } } }Mathf.Lerp从初始间隔平滑过渡到最小间隔,这比直接改 spawnInterval 的做法体感自然得多。这里还可以加一条:用 sin 函数制造一波一波的“小怪潮”,比如每 20 秒来一波加速生成。答辩时你可以说“我用双波形叠加控制生成节奏,基础密度递增 + 周期性爆发”,效果和描述都会让人信服。
权重选怪方面,我建议给每种敌机一个spawnWeight,不用数组顺序随机,而是按权重累计区间随机。这样可以避免随机完全均匀导致后期出现大量精英怪。更妙的是,前 30 秒把精英怪的权重设为 0,后期递增,就自然形成了难度曲线。
4.3 敌机移动模式:直线、正弦、追踪三种至少实现两种
不要把敌机都做成直线飞行。太空射击的趣味很大程度来自不同移动模式的敌机组合。最少实现两种,推荐实现三种:直线匀速、正弦横向摆动、追踪玩家。
三种模式的实现差别只在 Update 里:
public enum EnemyMoveType { Straight, SineWave, Chase } public EnemyMoveType moveType; public float sineSpeed = 2f; public float sineFrequency = 3f; public float chaseSpeed = 5f; private float sinePhase = 0f; void FixedUpdate() { switch (moveType) { case EnemyMoveType.Straight: transform.position += Vector3.forward * (speed * Time.fixedDeltaTime); break; case EnemyMoveType.SineWave: Vector3 baseMove = Vector3.forward * (speed * Time.fixedDeltaTime); transform.position += baseMove; float xOffset = Mathf.Sin(Time.time * sineFrequency + sinePhase) * sineSpeed * Time.fixedDeltaTime * 10f; transform.position += Vector3.right * xOffset; break; case EnemyMoveType.Chase: Vector3 dir = (player.position - transform.position).normalized; transform.position += dir * (chaseSpeed * Time.fixedDeltaTime); break; } }追踪模式需要注意一点:chaseSpeed必须低于玩家moveSpeed,否则玩家甩不掉追踪机,会一直被贴脸。直线模式比较简单,但要注意当敌机飞出玩家视野后要回收,否则僵尸对象越积越多。判断方式是用transform.position.z是否超出某个边界值,不要等到相机彻底看不见才回收,留一点余量。
这三种模式配合生成权重,2 分钟一局的体验能有明显变化:前期直线机攒经验,中期来正弦机练预判,后期追踪机考验走位。这套组合拳,答辩演示时也能直观看到你“做了设计”,而不是随机乱刷。
4.4 炸毁回收:别直接 Destroy,把敌机还给池子才算闭环
敌机被打到血量为 0 后,很多初学者会调用Destroy(gameObject)。在对象池架构里这是错误操作——你会破坏池的一致性,下次 Get 返回的可能是已销毁的对象,然后会报 MissingReferenceException。
正确做法和子弹一样,调用ObjectPool.Instance.Release("Enemy", gameObject)。不过敌机有逻辑上的“死亡动画”,所以要分两步走:先触发爆炸特效和音效,然后延迟回收。延迟回收用协程:
public void Die() { // 先关闭碰撞,防止死亡瞬间还能被打出伤害 GetComponent<Collider>().enabled = false; // 播放爆炸特效和音效 EffectSpawner.Instance.SpawnExplosionEffect(transform.position); AudioManager.Instance.PlayExplosion(); // 设置敌方状态 isDead = true; // 延迟回收,留给特效播放时间 StartCoroutine(ReleaseAfterDelay(0.5f)); } IEnumerator ReleaseAfterDelay(float delay) { yield return new WaitForSeconds(delay); ObjectPool.Instance.Release("Enemy", gameObject); }这里把 Collider 关闭很重要,否则死亡瞬间如果子弹又打过来,会二次触发 TakeDamage,而血已经是 0 了,就可能出现负数血、重复掉分、重复播放特效。协程延时 0.5 秒是为了让爆炸特效播完再回收,回收时机不是立刻执行再单独做特效,而是让特效也变成池内对象,播放完毕跟着收回。
5. 避坑专题:碰撞不触发、子弹穿墙、卡片顿、UI 不更新,都在这里
5.1 子弹直接穿过敌机,OnTriggerEnter 死活不触发
现象:敌机在正前方,子弹能笔直穿过,但控制台没有任何日志。这是我帮 A 同学调试作业时遇到次数最多的问题。原因基本是固定几个之一:一是敌机或子弹上的 Collider 没有勾 isTrigger;二是子弹带 Rigidbody 但设置了 Use Gravity,且没有设置 isKinematic,导致子弹飞行轨迹被重力拉弯;三是 Layer 碰撞矩阵屏蔽了 Bullet 和 Enemy 层,物理引擎根本不会把两者做碰撞检测;四是速度太快,每帧位移距离超过碰撞体厚度,子弹“跳跃”了过去。
排查顺序很固定:第一步在 OnTriggerEnter 里加一行 Debug.Log,确认碰没碰到;第二步检查 Bullet 和 Enemy 预制体上 Collider 的 isTrigger 是否都勾选;第三步在 Edit→Project Settings→Physics 里检查 Layer Collision Matrix,看 Bullet 和 Enemy 之间是否是启用状态;第四步把子弹速度从 30 临时调成 5,看是否能触发——能触发就是速度过快,解决方法不是调慢速度,而是把 Rigidbody 的 Collision Detection 设为 Continuous 或 Continuous Dynamic。另外子弹上如果是transform.position +=方式移动而非刚体移动,建议改用Rigidbody.MovePosition,碰撞检测会更稳定。
5.2 对象池里的子弹第二次发射方向不对
现象:第一发子弹正常,第二发开始子弹往斜上方飞或者飞回玩家。原因是子弹预制体里包含了旋转信息,第一次 Instantiate 时按默认旋转,回收时没有重置 rotation。对象池复用的物体会保留上次使用的状态,所以需要在 OnEnable 里强制transform.rotation = Quaternion.identity,或者从发射点复制旋转。我在 Bullet 的 OnEnable 里会额外加一行复位旋转的代码,不要依赖发射点每次设置。
之前我在玩家逻辑里写了b.transform.rotation = fp.rotation,如果你照抄了,理论上每次发射都会设置旋转,似乎不会出问题。但有一种情况会翻车:PlayerController 里的发射点在启动瞬间还没被正确赋值(Inspector 里漏拖了),fp.rotation 是默认值,子弹就会按默认方向飞。所以稳妥做法是 Bullet 脚本 OnEnable 里复位旋转,并且在发射代码里加上空引用判断。
5.3 UI 分数更新“慢半拍”或压根不更新
现象:玩家击毁敌机,得分逻辑执行了(Debug.Log 有输出),但 UI 上的 Text 不刷新。原因基本是代码直接访问 Text 组件,但没有在 Start 里缓存引用,或者用了Text而不是TMP_Text。Unity 新版本默认用 TextMeshPro,旧版注册表里 Text 组件和相关 API 已经不推荐使用。解决办法是统一用 TextMeshPro,在 GameManager 里缓存TextMeshProUGUI引用,每次分数变化时调用UpdateScoreUI(),而不是每次访问GetComponent。
一个低级但常见的问题:UI Text 被背景图挡住了,分数更新了但看不见。这时把 UI 的顺序往下调,或者在 Scene 视图里把 Canvas 的 Sorting Order 调成最高。这种“看不见就是没更新”的误判,调试时会浪费很多时间。
5.4 游戏分辨率不同,边界值不准导致飞机飞出屏幕
现象:在你电脑上测试飞机移动正常,换一台显示器或改分辨率后,飞机能飞出去一大截才被夹住。原因是边界值写死了屏幕像素值,而不是用相机可视范围计算。正确做法是运行时根据相机动态算边界:取相机的ViewportToWorldPoint,屏幕左下角为 (0,0),右上角为 (1,1),分别算出世界坐标的四个角,再把边界设成世界坐标值。这样不管分辨率怎么变,飞机始终在屏幕内。这个逻辑放在 GameManager 的 Awake 里,在 PlayerController 移动前算好并注入到它的 boundX 和 boundY 里。
5.5 游戏长时间运行,帧率越来越低
现象:刚启动 60 帧,玩到第 3 分钟掉到 30 帧,越玩越卡。原因大多数是对象池没回收干净,或者特效对象没有进池子。检查方法是打开 Profiler(Window → Analysis → Profiler),看 CPU 耗时和内存占用是否持续增长。最常见的问题是 ParticleSystem 特效在 Play 后没有自动销毁也不回池,导致无数特效对象堆积。我加了一个简单的计数器脚本挂到特效预制体上,Start 协程延迟 1.5 秒后自动回收,这样不管代码里什么时候触发,特效终会归还池子,不会成为泄漏点。
6. 进阶打磨:UI 事件驱动、自动波次、答辩演示的三个加分细节
6.1 不用 Update 轮询 UI,改用事件回调
如果你把分数变化写在 Update 里每帧刷新,数值变了自然能显示,但一个纯数字这样刷是没问题的。可如果将来要显示生命值条、护盾条、连击数,Update 里每帧同步所有 UI 节点就会白白消耗性能,还会出现“UI 比游戏逻辑慢一帧”的体感延迟。
我习惯让 GameManager 把分数变化做成 C# 事件public event Action<int> OnScoreChanged,UI 脚本在 OnEnable 里订阅,在 OnDisable 里取消订阅。这样分数只在真正变化时更新一次 UI,也不会有 UI 脚本一直空转的问题。代码结构不算复杂,但答辩时能体现出你有工程意识,而不仅仅是“能跑”。
事件写法核心就两行:
public event System.Action<int> OnScoreChanged; public void AddScore(int value) { score += value; OnScoreChanged?.Invoke(score); }UI 脚本里订阅:
private void OnEnable() => gameManager.OnScoreChanged += RefreshScore; private void OnDisable() => gameManager.OnScoreChanged -= RefreshScore; private void RefreshScore(int newScore) { scoreText.text = "得分 " + newScore.ToString(); }这个写法有个隐藏好处:如果后期要把分数同步到排行榜或保存到存档,只需要再增加一个订阅者,不用改 GameManager 内部逻辑。如果只给期末作业用,事件订阅略有一点过度设计,但考虑到老师常问“代码怎么扩展”,这算是合格的加分项。
6.2 自动波次系统:不写死时间点,用事件驱动换场
我做模拟项目时用过一个简单的波次系统:每个波次定义一个生成列表、持续时间和结束条件,当一个波次的敌机全部清空后自动进入下一波。这在作业里是一个很能拿分的点,而且实现并不复杂。
波次的核心是一个 ScriptableObject,里面存这个波次的三个数据:要生成哪些敌机、以什么频率生成、清空条件(通常是消灭 N 个或坚持 T 秒)。Spawner 在波次开始后按配置生成,当存活敌机数归零且生成数量达到目标时,触发 OnWaveCompleted 事件,GameManager 收到后加载下一个波次。
[CreateAssetMenu(fileName = "WaveConfig", menuName = "Game/WaveConfig")] public class WaveConfig : ScriptableObject { public EnemySpawnData[] spawnData; public float waveDuration = 30f; public int scoreTarget = 500; }用 ScriptableObject 做配置的好处是答辩时可以直接在 Inspector 里调数值,现场演示“把第二波生成间隔调紧一点”,不需要重新编译,这个细节非常讨喜。不过注意别把存档数据放 ScriptableObject 里——它是运行时可写的,但退出编辑器后改动会被覆盖,存档应放到 PlayerPrefs 或文件里。
6.3 答辩演示:录屏前先把这三件事调好
期末作业的评分很大程度取决于演示是否流畅。我建议在交作业前强制检查三件事。
第一,关掉场景里所有不必要的 Debug.Log。大量 Debug.Log 在 Game 视图下方开会掉帧,而且答辩视频录进去会显得很不专业。保留 GameManager 里几个关键信息输出,把子弹和敌机的每帧输出全部注释掉。
第二,调好相机的跟随平滑。如果你用transform.position = player.position + offset这种硬跟随,高速移动时机身会有抖动感。用一个简单的 Lerp 平滑跟随就好:
Vector3 targetPos = player.position + offset; transform.position = Vector3.Lerp(transform.position, targetPos, 0.1f);这个 0.1 别调太高,太高中断感强,太低会拖尾。0.1 是我个人常用的手感值,你可以根据游戏体感微调。
第三,打包成 Windows 或 WebGL 之前先在 Build Settings 里把场景拖进 Scenes In Build,并确认 Player Settings 里 Company Name 和 Product Name 不是默认的 DefaultCompany。很多同学录完视频才发现打不开包,原因就是场景忘了加进 Build。打包前先跑一遍 Building 流程测试,别到答辩前一晚才第一次点 Build。
最后一件事,答辩时最好准备一个 30 秒的录屏,展示三种敌机行为、对象池复用前后的帧率对比、波次系统切换。把自己做的技术点明明白白摆出来,比现场慌慌张张翻代码强得多。我做这类项目最深的教训是:功能多不如演示顺,代码好不如老师看得懂。你自己闭着眼能说清楚的地方,重点展示;说不清的部分,宁可拆掉也别硬留。希望帮到你。
本文还有配套的精品资源,点击获取