Unity 2D飞行射击游戏开发实战:雷霆战机演示工程全拆解
2026/9/8 18:18:58 网站建设 项目流程

简介:这是一份Unity 2D游戏开发学习用的“雷霆战机”演示项目压缩包,适合刚接触Unity的初学者或希望了解2D射击游戏结构的开发者参考。包内共有1740个文件,包含105个dll运行库、104个meta资源索引、33个hdr贴图、24个mat材质、17个unity场景、6个prefab预制体和6个cs脚本等,覆盖了从场景搭建、材质贴图到脚本控制的完整工程链,压缩包整体约455MB。资源已被512人学习使用,具有一定的参考价值。通过这个演示项目,读者可以直观看到2D战机游戏的场景组织、材质配置、预制体与脚本的关联方式,也能了解Unity工程中常见文件类型的用途与整体目录结构,适合配合教程边看边拆解。 前段时间有个朋友问我,Unity 2D到底能不能做出小时候街机里那种竖版飞行射击游戏。我说别问能不能,直接给你看我自己整理的雷霆战机演示工程。这个项目不算大,但麻雀虽小五脏俱全——玩家机控制、自动开火、敌机生成、碰撞爆炸、计分UI这几条主干全都有,正好是Unity 2D STG类型最典型的一套流程。

如果你正好想学Unity 2D,或者想做一款竖版射击游戏但不知道从哪下手,这个演示文件就是很好的起点。它不涉及复杂的网络同步、不做重度养成系统,核心就是把“飞机能飞、子弹能射、敌机能炸、分数能涨”这条闭环跑通。如果你是想快速理解这类游戏的技术结构,或者准备照着做一款自己的飞行射击游戏,这篇拆解可以帮你省下不少自己摸索的时间。

1. 这个演示项目到底演示了什么:先盘一盘STG的核心模块

很多人一听到“雷霆战机”这类名字,第一反应是“这不得写一大堆算法?子弹轨迹、敌机AI、关卡设计……”实际上,拆开看核心循环就那么几件事:玩家机移动、发射子弹、子弹打中敌机、敌机爆炸、刷新下一波、分数更新。

这个演示工程的核心价值,恰恰是把这几件事用Unity 2D最基础的手段串了起来。它不是商业项目那种动辄几十个类的架构,而是以“能看懂、能复现、能改动”为优先的教科书式写法。

从模块划分上看,整个工程大致包含这么几块:

  • 场景与摄像机配置:竖版画幅、正交相机、背景滚动
  • 玩家机控制:键盘输入、屏幕边界限制、发射子弹
  • 子弹管理:对象池复用、方向控制、生命周期
  • 敌机生成:定时生成、随机位置、向下移动
  • 碰撞处理:子弹与敌机、敌机与玩家机、爆炸特效触发
  • UI状态:生命值、得分、游戏结束

整个工程跑起来后,你会看到一架小飞机在屏幕下方,自动或按键射出一串串子弹,屏幕上方不停掉敌人,打中后爆出火花,分数跳动,漏过太多敌人或撞上敌机则扣血,血扣完就Game Over。

这就是STG(Shoot 'em Up,竖版射击游戏)最基础的“骨架”。骨架有了,后面什么激光、散射、Boss战、导弹追踪,都是在这些骨架上长肉。所以我一直觉得,这类演示文件最大的价值不是让你照着抄,而是让你通过一套最小的可运行闭环,理解游戏逻辑和Unity组件之间的配合关系。

顺带多说一句,网上也经常能看到用SFML这类多媒体库做“雷霆战机”案例的思路。SFML的好处是更贴近底层,什么都要自己搭,适合练习C++和图形学基础;而Unity 2D的优势在于物理、资源管理、UI、粒子这些管线都给你备好了,你可以把精力集中在玩法逻辑上。如果想快速出效果,Unity 2D明显更合适。

2. 项目搭建前的几个关键决定:版本、渲染模式与物理配置

拿到演示工程后,不要急着进Scene就开跑。先看几个基础配置,这些配置决定了后面所有脚本写起来顺不顺手。

2.1 版本与模板选择

我用的是Unity 2021 LTS以上版本,模板用默认的2D模板或者3D模板都行。老实说,2D和3D模板最大的区别只是默认的摄像机投影类型和灯光设置,只要你自己把摄像机改成Orthographic(正交投影),画面感觉完全一样。

这里有个小经验:如果你的项目不需要复杂的粒子光照、不需要后期特效,用默认渲染管线就够了。URP确实对2D有专门的Sprite Light和2D Renderer,但从演示项目的复杂度来看,没必要为了“性能更好”去折腾管线切换。URP的2D光源适合做氛围,入门阶段先跑通逻辑最重要。

2.2 竖版画幅的本质:正交相机与背景滚动

雷霆战机是竖版游戏,所以摄像机设置很关键。把主摄像机设为Orthographic,然后调Size。我习惯把摄像机的Size调到5左右,这样屏幕高度约是10个单位,宽度取决于Game视图的宽高比。

背景滚动这块,演示工程里通常有两种做法:

  1. 用一个大Quad或Sprite做成长条形背景图片,脚本里每帧向下移动一小段距离,到了阈值就重置回上方。
  2. 用两张背景图交替跟随,当第二张完全进入画面时,把第一张挪到第二张上方,形成循环。

第二种做法我更喜欢,因为不会在重置瞬间穿帮。核心代码思路很简单:

public class ScrollingBackground : MonoBehaviour { public float speed = 2f; public float resetPosY = -10f; public float startPosY = 10f; void Update() { transform.Translate(Vector3.down * speed * Time.deltaTime); if (transform.position.y <= resetPosY) { transform.position = new Vector3(0, startPosY, 0); } } }

刷背景时记得把Sprite的Draw Mode设为Tiled,或者直接用一张宽高比例合适的图片。如果你做了两张背景交替,把它们的Sorting Layer设成Background,不会挡住子弹就行。

2.3 2D物理世界和碰撞矩阵的初始配置

Unity的碰撞器默认是全图层互相检测的,但这在STG里是灾难。玩家子弹不能打到自己人,敌机子弹不用撞敌机,玩家机也不能和背景碰撞。所以第一步就是给游戏对象分层。

我习惯在Layer里建这么几层:

  • Player(玩家机)
  • Enemy(敌机)
  • PlayerBullet(玩家子弹)
  • EnemyBullet(敌机子弹,如果后续要加)
  • Obstacle(障碍物,酌情添加)

Layer建好之后,打开Edit -> Project Settings -> Physics 2D,把Layer Collision Matrix里不需要检测的勾选去掉。例如:

  • PlayerBullet只检测Enemy、Obstacle
  • Enemy只检测Player、PlayerBullet
  • Player只检测Enemy(以及EnemyBullet)

这一步不改的话,你会遇到子弹一碰到另一颗子弹也触发爆炸这种诡异问题。分层和碰撞矩阵,属于Unity 2D项目中“前期5分钟,后期省两小时”的典型配置,一定不要跳过。

3. 玩家操控与子弹系统的实现逻辑

玩家机是玩家和游戏世界直接交互的载体,手感好不好,往往就在移动和射击这两个环节。

3.1 移动方案:直接改Transform还是走物理

玩家机的移动,我在演示工程里用的是Rigidbody2D加velocity的方式,而不是直接transform.Translate。

原因很简单:直接改Transform帧位移,运动的每一帧都覆盖掉物理系统对物体的速度计算,碰到碰撞器时容易产生抖动或穿透;用Rigidbody2D的velocity,物理引擎会统一处理移动和碰撞,手感更顺滑。

public class PlayerController : MonoBehaviour { public float moveSpeed = 8f; private Rigidbody2D rb; private float minX, maxX, minY, maxY; void Start() { rb = GetComponent<Rigidbody2D>(); Camera cam = Camera.main; float halfHeight = cam.orthographicSize; float halfWidth = halfHeight * cam.aspect; minX = -halfWidth + 0.5f; maxX = halfWidth - 0.5f; minY = -halfHeight + 0.5f; maxY = halfHeight - 0.5f; } void Update() { float h = Input.GetAxisRaw("Horizontal"); float v = Input.GetAxisRaw("Vertical"); rb.velocity = new Vector2(h, v).normalized * moveSpeed; } void LateUpdate() { Vector3 pos = transform.position; pos.x = Mathf.Clamp(pos.x, minX, maxX); pos.y = Mathf.Clamp(pos.y, minY, maxY); transform.position = pos; } }

注意GetAxisRaw和GetAxis的区别。演示项目里我一般用Raw,因为摇杆和键盘的手感更直接,没有缓动延迟;GetAxis默认会带一点平滑处理,适合赛车类游戏,不太适合飞机这种需要即时响应的操控。这里你可以自己感受后调整。

边界限制放在LateUpdate里做Clamp,是因为如果放在Update里,帧末物理更新可能会再次微调位置,偶尔会出现一帧穿边的情况。LateUpdate时机更靠后,视觉上更稳。

3.2 自动射击与对象池:为什么不能直接Instantiate

新手写射击最简单的做法是开火时Instantiate一颗子弹,等子弹飞出去、出界或命中后Destroy。这个逻辑写起来很顺,但跑起来帧率会很难看。

STG游戏子弹是高频对象,一秒钟可能生成几十上百颗,Instantiate和Destroy会产生大量GC Alloc,每帧都在分配内存、每几秒就触发一次垃圾回收,表现就是卡顿。你可以在Profiler里看到明显的GC峰值。

演示工程里我用了对象池,核心思路是:提前创建一批子弹对象,不激活,需要发射时从池子里取一个,用完后放回去,而不是销毁。

public class BulletPool : MonoBehaviour { public GameObject bulletPrefab; public int poolSize = 30; private Queue<GameObject> pool = new Queue<GameObject>(); void Awake() { for (int i = 0; i < poolSize; i++) { GameObject obj = Instantiate(bulletPrefab); obj.SetActive(false); pool.Enqueue(obj); } } public GameObject GetBullet(Vector3 pos, Quaternion rot) { if (pool.Count == 0) { return null; // 池子不够用时要扩容 } GameObject obj = pool.Dequeue(); obj.transform.position = pos; obj.transform.rotation = rot; obj.SetActive(true); return obj; } public void ReturnBullet(GameObject obj) { obj.SetActive(false); pool.Enqueue(obj); } }

取子弹时一定要重置位置、旋转和状态,这是我们这类项目里最常见的一个坑,后面会单独说。对象池对象不用做成独立的全局单例,挂到玩家机上或场景里,通过Inspector引用即可。

3.3 子弹的生命周期管理

子弹从枪口飞出后,要么命中目标,要么飞出屏幕。负责回收的脚本通常在子弹自己身上:

void OnBecameInvisible() { bulletPool.ReturnBullet(gameObject); }

OnBecameInvisible在对象离开所有摄像机视野时触发,非常契合子弹出屏回收的场景。需要注意的是,对象池的Return方法最好由子弹上的脚本调用,并且引用池组件的方式可以用事件或静态引用,这里不做复杂解耦,直接GetComponent或在Awake时缓存都行。

4. 敌机系统与碰撞检测的几种常见写法

敌机系统和玩家系统有很多相似之处,但也有几个独立的设计问题:生成方式、移动模式、碰撞后归谁处理。

4.1 敌机的生成:协程定时器与随机位置

敌机生成我习惯用协程做一个类似生成器的管理器,而不是写很多Update来判断时间。这样逻辑更直观。

IEnumerator SpawnLoop() { while (true) { SpawnEnemy(); float waitTime = Random.Range(0.8f, 2f); yield return new WaitForSeconds(waitTime); } }

SpawnEnemy里从对象池取敌机,摆到屏幕顶部的随机x位置,初始速度设好,然后让它自己往下飞。敌机的飞行逻辑非常简单,通常就是:

rb.velocity = Vector2.down * speed;

有些敌人会左右摇摆,那就在Update里加一个Mathf.Sin的偏移;有些敌人是直线冲撞,那就把速度调大。演示文件一般只做直线,但你在理解之后可以自己加sin波。

4.2 碰撞检测:为什么我用Trigger而不是Collision

Unity 2D的碰撞事件有两类:OnTriggerEnter2D和OnCollisionEnter2D。区别在于触发对象是否为Trigger状态。Collision会模拟物理碰撞响应,两个刚体碰在一起会互相弹开;Trigger则只通知事件,不产生物理阻挡。

在STG游戏里,子弹打中敌机后,我们希望的是“子弹消失、敌机爆炸”,而不是“子弹被弹开、敌机被顶飞”,所以这里用Trigger是唯一合理的选择。

我的配置习惯是:

  • 玩家机:Rigidbody2D(Dynamic或Kinematic)+ BoxCollider2D(IsTrigger = false)
  • 子弹:Rigidbody2D(Kinematic)+ Box/CircleCollider2D(IsTrigger = true)
  • 敌机:Rigidbody2D(Kinematic)+ Box/CircleCollider2D(IsTrigger = true)

Rigidbody类型这里要注意。子弹和敌机如果用Dynamic,那么它们会受重力影响,2D默认重力会让物体往下掉,反而方便,但Dynamic的子弹撞到Dynamic的敌机还是会有物理反馈,哪怕碰撞器是Trigger,物理系统的动态计算依然存在。为了稳定,子弹和敌机我一般用Kinematic,这样它们不会因为碰撞被打乱轨迹,只会接收触发事件。

碰撞回调写在哪一侧?我习惯在子弹侧判断:

void OnTriggerEnter2D(Collider2D other) { if (other.CompareTag("Enemy")) { other.GetComponent<Enemy>().TakeDamage(1); bulletPool.ReturnBullet(gameObject); } }

这里要注意Tag的用途。Layer适合做碰撞矩阵的过滤,Tag适合做事件响应时的类型判断。你不能只用Layer不走Tag,因为同一Layer上可能有多种对象。如果不用Tag,你可能会把玩家子弹误判为敌机子弹,导致奇怪的互相抵消。

4.3 敌机的死亡流程:爆炸表现与回收

敌方飞机的TakeDamage逻辑是STG的一个小核心,它牵涉到状态管理和奖励结算:

public void TakeDamage(int damage) { hp -= damage; if (hp <= 0) { Die(); } } void Die() { if (isDead) return; isDead = true; if (explosionPrefab != null) { GameObject fx = Instantiate(explosionPrefab, transform.position, Quaternion.identity); Destroy(fx, 2f); } PlayerScore.Instance.AddScore(pointValue); gameObject.SetActive(false); }

注意Die里的isDead标志位。如果不加这个,当子弹和敌机在同一帧触发两次碰撞时(例如两颗子弹同时命中),Die会被调用两次,分数会加两次,爆炸特效会生成两遍。虽然看起来是小概率事件,但在高速射击游戏里,两颗子弹命中同一个目标的概率比你想象中高得多。

爆炸特效这块,我建议用Unity的ParticleSystem而不是一堆Sprite动画。粒子的优点是无需写播放器,挂上去设置好就自动播放,播放完通过StopAction设为Destroy就可以自动清理,非常省心。缺点是你需要花点时间去调粒子参数,不过调完一次存成Prefab,后面所有飞机爆炸都能复用。

5. 演示项目里最容易翻车的几个细节

演示文件本身能跑起来,不代表它没有坑。相反,很多看起来正常运行的项目,藏着几个只有改过代码才知道的问题。这里把我踩过的几个典型坑单独拎出来说,你之后自己写的时候能少走弯路。

5.1 碰撞事件触发两次,分数和特效重复

最典型的问题是子弹打中敌机时,因为子弹和敌机都带Rigidbody2D,两颗子弹同时命中,或敌方飞机本体与子弹发生了多帧接触,导致OnTriggerEnter2D在同一目标上连续触发。如果你在回调里没有加防重复处理,就会看到爆炸特效闪了两下、分数加了两次。

解决办法有两个:

  1. 在TakeDamage和Die里加isDead标志,保证死亡流程只走一次。
  2. 在碰撞回调里,回调第一句就判断对方是否还激活,如果不激活直接return。

第一招是根本解法。因为不管什么原因触发了碰撞,只要进入死亡流程,就必须保证不可重复。

5.2 对象池复用的对象状态没重置

第二个坑是子弹从对象池取出来时,位置、速度、旋转都没有重置。表现出来就是:取出来的子弹呆在上一次消失的位置,飞起来方向还是旧的,或者明明取出来了却看不见。

我之前写对象池时也翻过车,后来养成习惯——取对象和收回对象都做一次完整的状态重置:

public GameObject GetBullet(Vector3 pos, Quaternion rot) { GameObject obj = pool.Dequeue(); obj.transform.position = pos; obj.transform.rotation = rot; obj.SetActive(true); var rb = obj.GetComponent<Rigidbody2D>(); rb.velocity = Vector2.zero; rb.angularVelocity = 0f; return obj; }

同时,子弹脚本自己的Awake或OnEnable里,也要重新设置移动方向、伤害数值、存活时间等参数。否则就会出现“换了个特效外观,但伤害还是上一颗子弹的”这种诡异问题。

5.3 粒子特效和UI的层级显示问题

Unity 2D的渲染顺序是由Sorting Layer和Order In Layer决定的,不是由场景里的物体顺序决定的。新手经常会发现爆炸特效被玩家机挡住,或者血条UI跑到子弹后面去了。

我的做法是建立一套固定的Sorting Layer顺序,从上到下大概是这样:

  • UI(最高)
  • Effect(粒子、爆炸)
  • Player
  • Enemy
  • Bullet(有的项目让子弹在敌人前面更合理,自己调整)
  • Background(最底)

同一个Layer内部,用Order In Layer细分。粒子特效的Order可以设高一点,保证它盖住飞机本身,这样爆炸瞬间看起来才不违和。

5.4 时间缩放与UI更新的配合

游戏结束或暂停时,Tine.timeScale会被设为0。问题在于,很多UI更新逻辑放在Update里,如果Update里的逻辑依赖deltaTime,一旦timeScale为0,UI可能就不会刷新。演示工程里常见的做法是在Game Over时直接设置最终文本,不要依赖持续刷新的协程。

另外,协程里如果用了WaitForSeconds,在timeScale=0时也会停住。所以游戏结束的延时处理我一般用WaitForSecondsRealtime。

6. 从演示到成品的扩展路线:如何长出一款完整STG

项目跑通后,下一个问题就是:怎么把它扩展成完整的游戏。演示工程的意义也不是让你停在“飞机打飞机”,而是给你一个可以往各个方向生长的地基。

第一优先加的是Boss战。你可以在原有的Enemy系统上扩展一个Boss类,拥有独立的血条、多阶段的弹幕行为。弹幕不需要复杂算法,定时从不同角度发射扇形子弹就很有Boss味。把现有的敌机对象池改造成可以容纳Boss子弹的对象池,逻辑完全复用。

第二是道具掉落。敌机死亡时有概率生成PowerUp,玩家机碰到道具后触发强化效果。比如子弹升级为双发、三发或激光。实现方式不复杂:在Enemy死亡时Instantiate一个道具Prefab,道具带Trigger碰撞器,玩家机碰撞后走Buff逻辑。

第三是屏幕震动和背景加速。这些是增加打击感的低成本手段。屏幕震动可以给摄像机写一个简单的偏移动画,背景加速则是在分数越过阈值后调高ScrollingBackground的speed参数。

第四是关卡配置。可以把波次信息写到一个ScriptableObject或JSON文件里,包括敌机类型、生成间隔、数量。这样策划调关卡不用改代码,这个演示工程瞬间就有了从“demo”走向“正式项目”的潜力。

说到底,这类项目最宝贵的不是代码本身,而是“一个系统如何组织起来”的思路。对象池解决的是高频对象的性能问题,碰撞矩阵解决的是物理事件过滤问题,状态标志解决的是事件重复触发问题。把这些思想装进自己的工具库里,比把代码抄到手更值。

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

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

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

立即咨询