ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Unity 2D飞行射击游戏开发实战:雷霆战机演示工程全拆解

Unity 2D飞行射击游戏开发实战:雷霆战机演示工程全拆解 简介这是一份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。这就是STGShoot 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视图的宽高比。背景滚动这块演示工程里通常有两种做法用一个大Quad或Sprite做成长条形背景图片脚本里每帧向下移动一小段距离到了阈值就重置回上方。用两张背景图交替跟随当第二张完全进入画面时把第一张挪到第二张上方形成循环。第二种做法我更喜欢因为不会在重置瞬间穿帮。核心代码思路很简单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、ObstacleEnemy只检测Player、PlayerBulletPlayer只检测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 GetComponentRigidbody2D(); 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 QueueGameObject pool new QueueGameObject(); 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而不是CollisionUnity 2D的碰撞事件有两类OnTriggerEnter2D和OnCollisionEnter2D。区别在于触发对象是否为Trigger状态。Collision会模拟物理碰撞响应两个刚体碰在一起会互相弹开Trigger则只通知事件不产生物理阻挡。在STG游戏里子弹打中敌机后我们希望的是“子弹消失、敌机爆炸”而不是“子弹被弹开、敌机被顶飞”所以这里用Trigger是唯一合理的选择。我的配置习惯是玩家机Rigidbody2DDynamic或Kinematic BoxCollider2DIsTrigger false子弹Rigidbody2DKinematic Box/CircleCollider2DIsTrigger true敌机Rigidbody2DKinematic Box/CircleCollider2DIsTrigger trueRigidbody类型这里要注意。子弹和敌机如果用Dynamic那么它们会受重力影响2D默认重力会让物体往下掉反而方便但Dynamic的子弹撞到Dynamic的敌机还是会有物理反馈哪怕碰撞器是Trigger物理系统的动态计算依然存在。为了稳定子弹和敌机我一般用Kinematic这样它们不会因为碰撞被打乱轨迹只会接收触发事件。碰撞回调写在哪一侧我习惯在子弹侧判断void OnTriggerEnter2D(Collider2D other) { if (other.CompareTag(Enemy)) { other.GetComponentEnemy().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在同一目标上连续触发。如果你在回调里没有加防重复处理就会看到爆炸特效闪了两下、分数加了两次。解决办法有两个在TakeDamage和Die里加isDead标志保证死亡流程只走一次。在碰撞回调里回调第一句就判断对方是否还激活如果不激活直接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.GetComponentRigidbody2D(); 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粒子、爆炸PlayerEnemyBullet有的项目让子弹在敌人前面更合理自己调整Background最底同一个Layer内部用Order In Layer细分。粒子特效的Order可以设高一点保证它盖住飞机本身这样爆炸瞬间看起来才不违和。5.4 时间缩放与UI更新的配合游戏结束或暂停时Tine.timeScale会被设为0。问题在于很多UI更新逻辑放在Update里如果Update里的逻辑依赖deltaTime一旦timeScale为0UI可能就不会刷新。演示工程里常见的做法是在Game Over时直接设置最终文本不要依赖持续刷新的协程。另外协程里如果用了WaitForSeconds在timeScale0时也会停住。所以游戏结束的延时处理我一般用WaitForSecondsRealtime。6. 从演示到成品的扩展路线如何长出一款完整STG项目跑通后下一个问题就是怎么把它扩展成完整的游戏。演示工程的意义也不是让你停在“飞机打飞机”而是给你一个可以往各个方向生长的地基。第一优先加的是Boss战。你可以在原有的Enemy系统上扩展一个Boss类拥有独立的血条、多阶段的弹幕行为。弹幕不需要复杂算法定时从不同角度发射扇形子弹就很有Boss味。把现有的敌机对象池改造成可以容纳Boss子弹的对象池逻辑完全复用。第二是道具掉落。敌机死亡时有概率生成PowerUp玩家机碰到道具后触发强化效果。比如子弹升级为双发、三发或激光。实现方式不复杂在Enemy死亡时Instantiate一个道具Prefab道具带Trigger碰撞器玩家机碰撞后走Buff逻辑。第三是屏幕震动和背景加速。这些是增加打击感的低成本手段。屏幕震动可以给摄像机写一个简单的偏移动画背景加速则是在分数越过阈值后调高ScrollingBackground的speed参数。第四是关卡配置。可以把波次信息写到一个ScriptableObject或JSON文件里包括敌机类型、生成间隔、数量。这样策划调关卡不用改代码这个演示工程瞬间就有了从“demo”走向“正式项目”的潜力。说到底这类项目最宝贵的不是代码本身而是“一个系统如何组织起来”的思路。对象池解决的是高频对象的性能问题碰撞矩阵解决的是物理事件过滤问题状态标志解决的是事件重复触发问题。把这些思想装进自己的工具库里比把代码抄到手更值。本文还有配套的精品资源点击获取
返回列表