简介:2024年Unity期末大作业“开炮打怪物”小游戏完整项目包,面向Unity初学者、计算机专业学生及课程设计者,提供塔防/射击类游戏从零实现的实战案例。压缩包共2000个文件、约54.97MB,以bin、meta、cs、dll、json等类型为主,其中cs脚本对应游戏逻辑,prefab与anim用于预制体和动画,asset、mat等管理场景与材质,png为UI/美术贴图,整体为可直接打开的Unity工程。游戏围绕炮台射击、怪物刷新波次、得分判定与UI交互展开,覆盖物理碰撞、游戏逻辑与UI交互等核心开发环节。已有200人学习下载,结合作者配套博文可深入理解项目规划、编码实现与常见问题修复,适合作为期末作业参考或Unity入门练手项目。
1. 先别急着解压:这个 unity 期末大作业藏着哪些值得抄的实战细节
一个“开炮打怪物”小游戏,是某高校 2024 年计算机相关专业的 Unity 期末大作业。它看着简单——炮台转、怪物来、炮弹飞、分数涨——但真正把它从头到尾跑起来,你会发现它把一个完整的 Unity 项目该有的模块都过了一遍:场景组织、C# 脚本、物理碰撞、UI 刷新、波次生成,甚至还有资源引用和打包配置这些平时上课不讲的黑匣子。适合两类人:一类是正在做或即将做 Unity 期末作业的学生,另一类是拿了别人的项目想快速上手复现、改造成自己版本的初学者。这篇笔记按我拆项目的顺序,从项目骨架讲到具体脚本,最后收敛到避坑和答辩前验证。
2. 从场景层级到脚本分工:先把项目骨架立住再谈截图
2.1 拿到项目后第一件事:先看 Hierarchy 而不是先看代码
很多初学者拿到别人项目,第一反应是打开所有 .cs 文件从头读。我一般会先在 Unity 里把场景完整打开,按下 Play 看一遍。跑起来之后,再打开 Hierarchy 面板看场景组织。你会发现这类“开炮打怪物”小游戏有个很典型的结构:
- Camera(主摄像机,负责跟随或固定视角)
- 炮台主体(包括炮管旋转节点和炮弹发射点)
- 怪物生成器(可能是个空物体挂着生成脚本)
- 怪物预制体,或运行期由生成器动态创建的怪物实例
- Canvas(渲染得分、血量、开始/结束界面)
- EventSystem(UI 事件交互必需)
这个层级本身就是项目文档。答辩时被问到“你这个项目怎么组织的”,直接把 Hierarchy 按从顶到底的顺序讲一遍,老师基本不会再追问架构问题。要注意的是,如果你拿到的项目里炮台和怪物的子物体命名是默认的 Cube、Sphere,建议先顺手改成有语义的名字,这一步能省后面大量调试时间。
我见过不少学生把脚本直接拖到场景里的 Cube 上,然后靠 GetComponent 互相找,结果一改 Prefab 就全乱。正确做法是按功能把脚本挂到对应节点:炮塔控制脚本挂炮塔根物体,怪物生成脚本挂一个空物体 Spawner,UI 脚本挂 Canvas。这样即使预制体被替换,逻辑也不会散。层级不清晰的项目会在后续加功能时爆炸,这不是危言耸听。
2.2 脚本按职责拆成六块,不要全塞在 Update 里
这类项目最常见的翻车是:所有逻辑全写在一个 MonoBehaviour 里,炮塔旋转、怪物生成、得分判断全在 Update 中堆。只要怪一多就掉帧,而答辩老师最常问的就是“你有没有考虑过性能”。我建议按下面这张表拆脚本:
| 脚本 | 职责 | 挂载位置 |
|---|---|---|
| TurretController.cs | 炮塔旋转与开炮触发 | 炮台根物体 |
| Shell.cs | 炮弹飞行与碰撞处理 | 炮弹预制体 |
| MonsterSpawner.cs | 波次生成与难度曲线 | 空物体 Spawner |
| Monster.cs | 怪物移动、血量、死亡逻辑 | 怪物预制体 |
| GameManager.cs | 游戏状态、得分、基地血量 | 空物体 GameManager |
| UIManager.cs | UI 刷新与面板切换 | Canvas |
不要小看这张表,它对应的是一个最简单的 MVC 分层:GameManager 管数据,UIManager 管显示,其余脚本管行为。拿 GameManager 举例,得分和基地血量是全局状态,如果直接放在某个怪物脚本里,怪物销毁时数据就没了,UI 也没法稳定获取。抽出来做成场景单例,是成本最低且最容易被答辩老师接受的方案。
public class GameManager : MonoBehaviour { public static GameManager Instance; // 场景单例,全局唯一入口 public int score; public int baseHealth = 10; private void Awake() { Instance = this; // 场景加载时赋值,脚本顺序无所谓 } public void AddScore(int point) { score += point; if (UIManager.Instance != null) UIManager.Instance.RefreshScore(score); } }逻辑说明:GameManager 用静态 Instance 暴露自己,怪物死亡时调用GameManager.Instance.AddScore(10),不需要知道 UI 具体怎么刷新。Awake 里赋值是为了保证场景内任何脚本在 Start 里访问时它已经存在。参数说明:如果项目里有两个 GameManager,这个写法会互相覆盖,所以场景里只能有一个挂这个脚本的物体。
UIManager 那边对应维护一个静态 Instance,RefreshScore方法里把得分 Text 的 text 字段更新成新的分数。UI 脚本只负责把数字塞进控件,不负责计算,这是避免“UI 和逻辑耦合”的关键。
3. 炮塔转向与开炮逻辑:控制、旋转与物理碰撞的细节
3.1 炮塔跟随鼠标旋转:Slerp 平滑与 z 轴陷阱
炮塔控制的代码量不大,但方向反了、角度偏了是高频 bug。核心逻辑是:把鼠标屏幕坐标转成世界坐标,算出炮塔到鼠标的方向向量,再用Mathf.Atan2转成角度。
void Update() { Vector3 mousePos = Camera.main.ScreenToWorldPoint(new Vector3( Input.mousePosition.x, Input.mousePosition.y, -Camera.main.transform.position.z)); Vector3 direction = mousePos - transform.position; float angle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg; Quaternion target = Quaternion.Euler(0, 0, angle - 90f); transform.rotation = Quaternion.Slerp(transform.rotation, target, 10f * Time.deltaTime); }逻辑说明:ScreenToWorldPoint的第三个参数是距离摄像机的 z 轴深度。如果炮塔在 z=0 平面,而摄像机在 z=-10,这个值要填 10,也就是-Camera.main.transform.position.z——填错了,鼠标位置会落在摄像机位置,炮塔会乱转。angle - 90f是因为 Sprite 默认朝上,而Atan2算出的是朝右的角度,要做一次偏移。Slerp 的第三个参数 10f 表示每秒向目标角度靠近,值越大转动越灵敏,1~15 之间可以现场试手感。
我一般会把rotationSpeed暴露成 public 变量,在 Inspector 里调,而不是写死。还有个习惯是取鼠标位置前先判断Time.timeScale——如果游戏暂停或 Game Over,炮塔还在转会显得很怪。可以在 Update 开头加一句if (GameManager.Instance != null && GameManager.Instance.IsOver) return;。
3.2 开炮:实例化炮弹与碰撞检测的两种物理方案
开炮逻辑看起来就几行Instantiate,但这里的坑比想象中多。炮弹预制体上要挂 Rigidbody2D(或 Rigidbody),还要选触发器(Trigger)还是碰撞器(Collider)。我的建议是:炮弹用触发器,怪物用碰撞器,或者反过来,但别两个都是普通碰撞器——那样会真实弹开,炮弹打到怪物弹飞,视觉上非常奇怪。
public GameObject shellPrefab; public Transform firePoint; public float fireForce = 600f; void Fire() { GameObject shell = Instantiate(shellPrefab, firePoint.position, firePoint.rotation); Rigidbody2D rb = shell.GetComponent<Rigidbody2D>(); rb.AddForce(firePoint.up * fireForce, ForceMode2D.Impulse); }逻辑说明:炮弹从firePoint位置生成,沿炮管朝上的方向给一个冲量。ForceMode2D.Impulse适合瞬间发射,不用管质量差异带来的速度波动。参数说明:fireForce=600f这个值不是固定的,让它等于炮管长度 × 预期的每秒飞行像素数大约是个安全起点;如果怪物体积大、密度高,这个值要往上加,否则炮弹飞一半就停了——别问我怎么知道。
炮弹预制体上还要有销毁机制,否则炮弹飞出屏幕外就永远不会删除,每开一炮泄露一个对象:
void OnTriggerEnter2D(Collider2D other) { if (other.CompareTag("Monster")) { other.GetComponent<Monster>().TakeDamage(damage); Destroy(gameObject); // 命中后销毁炮弹 } } void OnBecameInvisible() { Destroy(gameObject); // 飞出屏幕也销毁 }逻辑说明:OnTriggerEnter2D只在炮弹挂 Trigger、怪物挂 Collider 时触发。命中怪物后调用取伤害方法,然后销毁自身。OnBecameInvisible是摄像头看不见物体时调用,防止炮弹飞出屏幕后一直处于活跃状态。这个方案在弹幕量不大时完全够用,但如果同时在场炮弹超过 30 发,就要考虑对象池了,否则每发炮弹的 Instantiate/Destroy 都会触发 GC 分配,帧率会肉眼可见地掉。
4. 怪物波次与 AI:生成器、寻路、血量与特效的完整链路
4.1 用协程做波次生成:比 Invoke 好得多的难度曲线控制
怪物生成器是这类游戏最容易写乱的地方。常见误用是写一堆InvokeRepeating,然后到某一波发现生成频率和数量没法精细控制,只能硬编码。我推荐用一个协程做主循环,把每波的数据放在一个可配置的结构里。
public class MonsterSpawner : MonoBehaviour { public GameObject monsterPrefab; public Transform[] spawnPoints; public WaveConfig[] waves; private void Start() { StartCoroutine(SpawnLoop()); } IEnumerator SpawnLoop() { for (int i = 0; i < waves.Length; i++) { WaveConfig w = waves[i]; for (int j = 0; j < w.count; j++) { Transform point = spawnPoints[Random.Range(0, spawnPoints.Length)]; Instantiate(monsterPrefab, point.position, Quaternion.identity); yield return new WaitForSeconds(w.interval); } yield return new WaitForSeconds(w.breakTime); } } } [System.Serializable] public class WaveConfig { public int count = 5; // 本波怪物数量 public float interval = 0.8f; // 生成间隔,单位秒 public float breakTime = 3f; // 波与波之间的休息时间 public float speedMultiplier = 1f; // 本波怪物的移速倍率 }逻辑说明:外层 for 循环遍历每一波,内层 for 按interval间隔依次生成怪物。yield return new WaitForSeconds让协程暂停到指定时间再继续,不会阻塞主线程。用[System.Serializable]标记 WaveConfig,可以让它在 Inspector 里可视化配置每一波的怪物数量、间隔和休息时间,比写死数组灵活得多。参数说明:speedMultiplier用来实现难度递增——在实例化怪物后,把它的移动速度乘上这个倍率,比每一波都重新做一整个预制体省事得多。
如果在实际项目里修改了波次数据,务必在 Inspector 里点一下面板右上角的 Apply,否则只有你本地看到新数据,Prefab 里的原始数据没变,打包后效果会“回退”。这个坑我踩过,具体表现是编辑器里测得好好的,Build 之后难度曲线全变了。
4.2 怪物移动:两种寻路做法的取舍
怪物移动是“朝基地走”的直线逻辑,但实现方式会影响性能。最直接的做法是每一帧让怪物朝目标点移动:
void Update() { if (target == null) return; Vector3 dir = (target.position - transform.position).normalized; transform.position += dir * moveSpeed * Time.deltaTime; }逻辑说明:normalized保证移动方向单位化,速度只受moveSpeed控制。这个做法适合基地固定、路径无遮挡的小游戏。如果场景里有障碍物需要绕路,则需要给怪物挂 NavMeshAgent,把烘焙好的 NavMesh 数据拖进场景——但这会增加项目复杂度,期末项目里大部分不需要。参数说明:moveSpeed推荐范围是 1.5~3.5,太快玩家来不及瞄准,太慢显得拖沓,而且要结合怪物体积来调。
另外一个藏在暗处的细节是:怪物到达基地后要触发扣血并销毁,不是堆在基地门口不动。可以在怪物上挂一个OnTriggerEnter2D,检测到“Base”标签时调用扣血并销毁自身。放一个 Base 碰撞体在基地周围,比在 Update 里每帧算距离可靠得多。
4.3 扣血与死亡的委托事件:不用到处找引用
怪物被炮弹打中要扣血,扣到零要播放死亡动画、加分、销毁。最稳定的写法是给怪物暴露一个TakeDamage方法,内部判断血量归零后触发一个事件,GameManager 订阅这个事件来加分。
public class Monster : MonoBehaviour { public int maxHealth = 3; private int currentHealth; public int scoreValue = 10; public event System.Action<Monster> OnDied; // 死亡事件,外部订阅 private void Start() { currentHealth = maxHealth; } public void TakeDamage(int damage) { currentHealth -= damage; if (currentHealth <= 0) { Die(); } } private void Die() { OnDied?.Invoke(this); // 通知订阅者,自己不做加分 gameObject.SetActive(false); } }逻辑说明:OnDied是一个 C# 事件字段,?.Invoke(this)是空安全检查写法,表示如果有订阅者就调用。GameManager 在怪物生成时订阅monster.OnDied += HandleMonsterDied,在 HandleMonsterDied 里加分并处理波次计数。这样怪物脚本不需要知道 GameManager 存在,解耦很干净。很多新手会给每个怪物一个 GameManager 引用,写死GameManager.Instance.AddScore,也能跑,但每次 Instantiate 都要手动挂引用,漏一个就崩,不如事件干净。
要注意SetActive(false)而不是Destroy(gameObject)。如果后面要接对象池,停用是必须的;即使不用对象池,尸体动画播完后停用也比销毁少一次 GC。如果老师问为什么用事件不用单例,就回答“降低模块间耦合”,这五个字在答辩里很有分量。
5. 避坑:开炮打怪物最容易翻车的五个位置
5.1 炮弹穿透怪物不生效
现象:炮弹从怪物身体穿过去,没有触发扣血,或者只有第一发命中后续全部穿透。原因通常有两个:一是炮弹预制体上没挂 Rigidbody,触发器在没有刚体的碰撞体上不会触发;二是炮弹飞行速度太快,加上连续碰撞检测没开,每帧移动距离超过了怪物体积,物理引擎直接跳过了这一帧的碰撞。
解决:炮弹预制体必须挂 Rigidbody2D,并且把 Collision Detection 从 Discrete 改成 Continuous。速度属性的单位是米/秒,不是像素/秒,如果 fireForce 调得过大,比如冲到 5000,那就要么降低数值,要么把怪物碰撞体大一点当容错。从实战看,在炮弹上同时设 Continuous 和合适的力,命中率可以达到 95% 以上。
5.2 炮塔旋转方向和鼠标完全相反
现象:鼠标往右移动,炮塔往左转。原因:炮塔朝上的 Sprite 图,角度计算时少了那 90 度偏移;或者父物体和子物体之间旋转关系有叠加。解决:先在场景中把炮管设为炮台根物体的子物体,代码里只控制炮管节点的旋转,根物体保持不动。如果方向反了,把angle - 90f改成angle + 90f,两种都试一次,哪个对准了鼠标用哪个。这个没什么玄学,就是偏移方向的问题,但确实能卡住人二十分钟。
5.3 怪物生成后立刻重叠叠在一起
现象:同一波怪物全部生成在同一个点,挤成一个团,炮弹一次炸一片,没有逐波压力。原因:生成点数组里只有一个 Transform 被赋了值,或者随机索引写错了。解决:在 Inspector 里确认 spawnPoints 数组长度和引用,建议在场景里放 3 个不同位置的空物体,代码里用Random.Range(0, spawnPoints.Length)随机选一个生成点。还要注意Random.Range在整数区间是包含下限、不包含上限的,索引越界问题多出现在这里。
5.4 得分 UI 刷新不同步或干脆不显示
现象:怪物死亡后分数没涨,或者游戏结束后分数还是上次的值。原因:UIManager 里的 Text 引用没拖进 Inspector,脚本访问时是 null;或者 GameManager 的 AddScore 调用时 UIManager.Instance 还没赋值,UI 被静默跳过了。解决:UIManager 和 GameManager 都用 Awake 里赋 Instance 的写法,并且 Text 引用在 Inspector 里拖好后再运行。调试时可以在 AddScore 里加一个Debug.Log,先确认加分逻辑有没有执行,再排查 UI 刷新。这个排查顺序很重要,逐层定位而不是乱猜。
5.5 编辑器里正常,打包后音效丢失或场景黑屏
现象:开发时一切正常,Build 之后要么背景音乐没有,要么直接黑屏。原因:音效文件没有在 Build Settings 里被引用,或者场景没加进 Build Scenes 列表,Unity 只打包了加入的场景及其依赖资源,漏掉任何一个场景都会黑屏。解决:打开 Build Settings,确认你的主场景在 Scenes In Build 列表里,并且已经把音效文件拖进项目内的 Resources 或通过 Inspector 引用。严格来说这是 Unity 打包的经典低级错误,但每个期末季都会有人中招。建议打包前先把整个项目所有场景资源都过一遍引用状态,再点 Build。
6. 答辩前这样验证:冒烟测试、性能检查和打包确认
6.1 5分钟冒烟测试清单
答辩前一天不要再加新功能,只做回归验证。按这张表过一遍,每一项都勾完再合电脑:
| 检查项 | 操作 | 预期结果 |
|---|---|---|
| 炮塔旋转 | 移动鼠标 | 炮管跟随且方向正确 |
| 开炮 | 点击左键 | 炮弹从炮口射出 |
| 炮弹命中 | 打中怪物 | 怪物扣血,炮弹销毁 |
| 怪物死亡 | 打空怪物血量 | 怪物停用,分数增加 |
| 怪物到达基地 | 不拦截 | 基地扣血,怪物消失 |
| 游戏结束 | 基地血量为0 | 弹出结束面板,炮塔停转 |
| 重开 | 点击重开按钮 | 分数清零,波次重新开始 |
6.2 开 Profiler 看两处数字
答辩时最怕被问“性能怎么样”,你不一定要解决所有问题,但得能说出来瓶颈在哪。启动游戏后按 Window -> Analysis -> Profiler,打开 CPU 和 Rendering 面板,重点看两点。第一是 Main Thread 时间有没有超过 16ms,超过就是掉帧;第二是 GC Alloc 那一栏,如果每秒分配都在 10KB 以上,说明有频繁的 Instantiate/Destroy,老师问到就说自己打算用对象池优化。
更简单的做法是在 Game 视图右上角点 Stats,看 SetPass calls 和 Triangles。对于这种 2D 小游戏,SetPass calls 在 20 以下就是健康的。
6.3 打包确认的最后一遍检查
Build Settings 里把目标平台选对,Windows 项目至少测试一次 x64_64 build。Player Settings 里把 Company Name 和 Product Name 改成自己的,不要留默认。脚本后端选 Mono 还是 IL2CPP:严格要求性能选 IL2CPP,但编译慢,期末项目选 Mono 更流畅。
从那以后我每次拿到一个 Unity 期末项目,都强制自己先跑一遍冒烟测试再打开脚本看逻辑,浪费的时间比想象中少得多。希望这个顺序也能帮到你,祝答辩顺利。
本文还有配套的精品资源,点击获取