☰
Unity2D雷霆战机演示文件拆解:场景、对象池与碰撞实战
2026/10/14 21:40:04 网站建设 项目流程

简介:这是一份Unity 2D游戏开发学习演示资源,以经典的“雷霆战机”飞行射击玩法为案例,面向初学Unity引擎、希望掌握2D游戏完整制作流程的开发者。资源共1740个文件,主要包括Unity场景与预设(unity/prefab)、材质贴图(mat、hdr、png)、C#脚本(cs)及插件依赖(dll、asset)等,压缩包整体约455MB,目录结构完整,便于对照工程文件理解游戏对象组织与资源管理方式。目前已有514人学习下载。通过该实战案例,学习者可以直观了解2D战机游戏的场景搭建、敌机生成、碰撞检测、UI计分及音效控制等核心模块的实现思路,同时也能看到内置资源打包与AssetBundle管理痕迹,适合作为课程设计参考,或Unity入门后的进阶动手练习。

1. 先说这是个什么文件:打开就能跑的雷霆战机演示文件,到底在解决什么问题

如果你在找 unity2d 游戏案例里的雷霆战机演示文件,多半不是看中它的美术,而是想知道一架飞机、一簇子弹、一群敌机和一套计分 UI 是怎么在没有复杂资源的情况下串成一条完整玩法的。这类演示文件的价值不在成品,而在过程:打开场景直接能跑,层级视图里能看到 Player、Spawner、Canvas 和 Pool 这类对象,脚本注释讲清楚每一段逻辑在干嘛。它解决的问题很具体:新手跟着教程做了十几个小功能,真到要做一个小游戏时不知道场景怎么组织、预制体怎么复用、碰撞和 UI 怎么协作,最终陷入改一处崩三处的循环。

这个演示文件适合三类人:刚学完 Unity 基础、想第一次独立完成小游戏闭环的开发者;需要快速搭玩法原型去讨论手感的美术或策划;以及想验证对象池、协程生成这类方案却不想从零开始的熟手。它给你的不是“一个游戏”,而是一套可拆的答案——输入、移动、生成、碰撞、对象管理和屏幕适配如何耦合。下面我会按拿到文件后的实际顺序走一遍:先拆工程、再跑核心玩法、然后调参数、最后讲那几个最容易让人卡住半天的坑。

2. 拆开雷霆战机演示文件:目录、预制体与场景是怎么串起来的

2.1 目录怎么切:把场景、脚本、预制体、贴图分开不是洁癖

拿到演示文件第一件事不是点 Play,而是先看 Assets 目录结构。常见做法是按资源类型切分,一套典型的目录长这样。

目录放什么为什么这样切
Assets/Scenes场景文件(.unity)入口只有一个,避免多场景互相覆盖
Assets/Scripts全部 C# 脚本按 Player、Enemy、UI、System 再分子目录
Assets/Prefabs预制体玩家、子弹、敌机、爆炸特效都挂这里
Assets/Sprites贴图、图集、材质与 Animations 分开,方便替换美术资源
Assets/AnimationsAnimator Controller 和 Animation Clip序列帧动画集中管理
Assets/Prefabs/UI按钮、文本框、Panel 预制体UI 与战斗对象混放时会很难排查

这种拆分不是洁癖,它决定了调试效率。你用脚本把 Player 拖到场景里能跑,但想给子弹换个贴图时,资源散落各处会在 Inspector 上暴露出大量丢失引用。真正常用的演示文件,Prefabs 目录里会有子弹、敌机、道具、爆炸特效这几个预制体,Scenes 目录里通常就是一个主场景和一个可选的菜单场景。

2.2 层级视图里先认清四个对象:Player、Spawner、Canvas 与相机

打开场景后,在 Hierarchy 里先找这四个关键对象,它们构成了整个玩法闭环。

对象作用你要注意的组件
Player玩家飞机,响应输入Rigidbody2D、Collider2D、PlayerController 脚本
EnemySpawner敌机生成器挂生成脚本,引用敌机预制体
CanvasUI 层CanvasScaler、TextMeshPro 文本、计分脚本
Main Camera2D 正交相机Projection 必须是 Orthographic,Size 决定可视范围

相机是很多人忽略的点。2D 游戏的相机 Projection 要设成 Orthographic(正交),这样物体大小不会因为纵深变化。相机如果被改成 Perspective,整个演示文件跑起来会有近大远小的透视变形,边界碰撞也会对不上。Orthographic 相机面板里的 Size 字段直接决定了世界坐标系下的可视高度,Size 越小画面越“放大”。

2.3 脚本与预制体的挂接方式:序列化字段为什么比 FindObjectOfType 更可靠

演示文件和正式项目最大差别在代码组织。教学 Demo 里经常有人写GameObject.Find("Player")或FindObjectOfType<PlayerController>()来找对象,能用,但每次场景结构一改就要跟着改字符串。成熟演示工程的通用做法是用 Inspector 上的序列化字段,让美术和策划都能看懂谁引用了谁。

using UnityEngine; public class EnemySpawner : MonoBehaviour { [Header("预制体引用,直接从 Inspector 拖拽")] public GameObject enemyPrefab; public Transform spawnParent; }

在 Inspector 里把敌机预制体拖进 enemyPrefab 字段,把存放敌机实例的父节点拖进 spawnParent,脚本和场景就“接上线”了。这样做的理由有三层:第一,预制体引用在编译阶段就能发现拼写错误,而字符串查找要等运行时才暴露;第二,Inspector 上的引用是序列化的,改场景后不会像 Find 一样拿到空引用;第三,策划可以直接替换预制体而不需要碰代码。

3. 把核心玩法闭环跑通:移动、射击、敌机生成与计分的四段代码

3.1 玩家移动的干净写法:Rigidbody2D 速度加视口坐标夹取

雷霆战机这类纵版飞行射击,手感第一来自移动速度,第二来自边界处理。最省事的写法是transform.Translate每帧挪一段距离,但这样会跳过物理系统,后续想加受击击退、惯性都会变得别扭。我一般用 Rigidbody2D 的 velocity 来驱动移动。

using UnityEngine; public class PlayerController : MonoBehaviour { [Header("移动参数")] public float moveSpeed = 12f; public float boundaryPadding = 0.5f; private Camera mainCamera; private Rigidbody2D rb; private void Awake() { rb = GetComponent<Rigidbody2D>(); mainCamera = Camera.main; if (mainCamera == null) { mainCamera = Camera.main; } } private void FixedUpdate() { // 用 GetAxisRaw 而不是 GetAxis,避免模拟摇杆的平滑延迟 float horizontal = Input.GetAxisRaw("Horizontal"); float vertical = Input.GetAxisRaw("Vertical"); // normalized 防止斜向移动速度变成单方向的根号 2 倍 Vector2 input = new Vector2(horizontal, vertical).normalized; rb.velocity = input * moveSpeed; // 边界用相机视口坐标换算,而不是 Screen.width / 2 Vector3 bottomLeft = mainCamera.ViewportToWorldPoint( new Vector3(0f, 0f, -mainCamera.transform.position.z)); Vector3 topRight = mainCamera.ViewportToWorldPoint( new Vector3(1f, 1f, -mainCamera.transform.position.z)); Vector3 pos = transform.position; pos.x = Mathf.Clamp(pos.x, bottomLeft.x + boundaryPadding, topRight.x - boundaryPadding); pos.y = Mathf.Clamp(pos.y, bottomLeft.y + boundaryPadding, topRight.y - boundaryPadding); transform.position = pos; } }

FixedUpdate里设置 velocity 属于标准做法,因为物理引擎的步长固定,速度计算会稳定。如果你把输入读取也挪到 FixedUpdate,输入延迟会略高,但对飞行射击这种大范围移动不明显。真实项目里更讲究的写法是把 Input 放 Update,把 velocity 赋值放 FixedUpdate,演示文件里合并写也没问题。

normalized是手感的分水岭。不归一化的话,玩家同时按“右”和“上”时速度会变成单方向的 1.414 倍,弹幕游戏里这种细微差异很容易让玩家觉得“斜着飞比直着飞快”。边界夹取用ViewportToWorldPoint而不是数值常量,是为了适配不同宽高比,后面第 4 章会展开讲。

3.2 子弹发射:先用 Instantiate 跑通,再换成对象池

子弹发射在演示文件里通常分两版。第一版用Instantiate和Destroy,代码短、逻辑直白,适合理解发射原理;第二版换成对象池,解决性能问题。

using UnityEngine; public class BulletController : MonoBehaviour { public float bulletSpeed = 15f; public float lifeTime = 3f; private void OnEnable() { // 对象池复用时重置生命周期,避免旧计时器提前回收 CancelInvoke(nameof(ReturnToPool)); Invoke(nameof(ReturnToPool), lifeTime); } private void Update() { // 子弹向上飞行,方向由预制体自身朝向决定 transform.Translate(Vector2.up * bulletSpeed * Time.deltaTime); } private void ReturnToPool() { gameObject.SetActive(false); } }

发射端的代码更简单。

public class PlayerShooter : MonoBehaviour { public GameObject bulletPrefab; public Transform firePoint; public float fireInterval = 0.15f; private float nextFireTime; private void Update() { if (Input.GetButton("Fire1") && Time.time >= nextFireTime) { nextFireTime = Time.time + fireInterval; GameObject bullet = Instantiate(bulletPrefab, firePoint.position, firePoint.rotation); Destroy(bullet, 3f); } } }

第一版用一个Destroy(bullet, 3f)定时清理,逻辑完整但问题明显:每发子弹都走了一遍内存分配和回收,连续射击 30 秒后 Profiler 里全是 GC Alloc。升级成对象池后,发射端只多两步:从池子里取对象,用完后关掉而不是删掉。

public class PlayerShooter : MonoBehaviour { public BulletPool bulletPool; public Transform firePoint; public float fireInterval = 0.15f; private void Update() { if (Input.GetButton("Fire1") && Time.time >= fireInterval) { bulletPool.Get(firePoint.position, firePoint.up); } } }

对象池实现可以给一个最精简版本。用一个队列存空闲对象,Get时从队列弹出一个,没有就新建;Return把对象关掉塞回队列。核心就两条规则:Get要做SetActive(true),Return要做SetActive(false),并且所有初始池化对象先在 Awake 里生成好。这样同一时间屏幕上的子弹数被池子容量兜住,不会因为瞬时连射把性能打穿。

3.3 敌机生成:协程、随机间隔与难度曲线的第一个旋钮

敌机生成几乎都是用协程写的,原因很现实:用 Update 做定时需要维护一堆nextSpawnTime状态,协程一个yield return就把逻辑写到一条直线上了。

using System.Collections; using UnityEngine; public class EnemySpawner : MonoBehaviour { public GameObject enemyPrefab; public Transform spawnParent; public float minInterval = 0.6f; public float maxInterval = 1.4f; private Camera mainCamera; private void Start() { mainCamera = Camera.main; StartCoroutine(SpawnLoop()); } private IEnumerator SpawnLoop() { while (true) { // 随机间隔让生成节奏不呆板,这是难度曲线第一个可调旋钮 float interval = Random.Range(minInterval, maxInterval); yield return new WaitForSeconds(interval); // 在屏幕顶部上方生成,玩家能看到敌机从视野外飞入 Vector3 topWorld = mainCamera.ViewportToWorldPoint( new Vector3(Random.value, 1.1f, -mainCamera.transform.position.z)); Vector3 spawnPos = new Vector3(topWorld.x, topWorld.y, 0f); GameObject enemy = Instantiate(enemyPrefab, spawnPos, Quaternion.identity, spawnParent); } } }

生成点选在ViewportToWorldPoint的 y=1.1,正好在屏幕顶部往上 10% 处。如果直接在 y=0.9(屏幕内)生成,玩家会觉得敌机是“突然冒出来的”,而不是“从远处飞来的”。Random.value只控制 x 坐标,让敌机沿屏幕宽度随机出现。这个生成脚本配合敌机自身的向下移动脚本就能完整呈现“敌机潮水般压过来”的效果。

要注意协程的停止条件。如果场景里有多个生成器,或者游戏可能切换到暂停状态,必须在 OnDisable 或 OnDestroy 里调用StopAllCoroutines(),否则场景销毁后协程还在运行,会产生空引用报错。

3.4 碰撞销毁与计分:触发器的配置顺序不能乱

碰撞是新手最容易翻车的一段。雷霆战机这种飞行射击游戏里,子弹、敌机、玩家三者的碰撞交互需要一套严格配置。常见做法是子弹和敌机之间用 Trigger(触发器),玩家和敌机之间用普通碰撞,但实际演示文件里为了省事常常全部勾成 Trigger。

对象Rigidbody2DCollider2DIs Trigger
子弹KinematicCircleCollider2D是
敌机KinematicBoxCollider2D是(或被子弹触发)
玩家KinematicCircleCollider2D否(需要挡下敌机)

碰撞回调写在子弹上还是敌机上?一般写在子弹上,因为子弹是“发起方”,它可以同时处理命中敌机、命中障碍和过期销毁三个分支。

private void OnTriggerEnter2D(Collider2D other) { if (other.CompareTag("Enemy")) { // 分数由 UI 层统一接收,这里只发信号 UIManager.Instance.AddScore(100); ExplosionManager.Instance.Spawn(transform.position); // 子弹和敌机都回收,敌机不在子弹里 Destroy gameObject.SetActive(false); other.gameObject.SetActive(false); } }

注意不要在里面直接Destroy敌机。Destroy是在当前帧结束时才真正删除对象,而 Trigger 回调可能在物理迭代的任意时刻触发,直接删除对象会导致引用野指针。正确做法是把敌机回收给敌机对象池,由池子统一处理生命周期。计分也不建议由子弹来写,UI 逻辑集中到 UIManager 里,后面换计分规则不用再翻射击脚本。

4. 演示文件调参要点:对象池容量、子弹速度与屏幕适配该怎么设

4.1 一张参数表调好手感:速度、间隔与判定半径怎么配

演示文件能不能“玩起来”,参数比代码更像决定性因素。给一组常用初始值作为手感基线。

参数推荐区间说明
玩家移动速度10 ~ 14低于 10 躲不开密集弹幕,高于 14 很难精确点射
边界留白 padding0.3 ~ 0.8留出手机边缘安全区
子弹速度12 ~ 18必须明显快于敌机下落速度,否则玩家会觉得子弹“无力”
敌机生成间隔0.5 ~ 1.5用随机区间,难度曲线从这里起步
敌机下落速度4 ~ 8与玩家速度同量级,保证碰撞有预判空间
单次击毁分数100 / 200 / 500不同敌机不同分值,强化目标优先级
对象池容量子弹 30、敌机 20模拟极端情况:满屏弹幕加 20 架敌机

调参数有个血泪经验:一次只改一个变量。把子弹速度从 15 调到 18 同时把生成间隔从 1.0 调到 0.6,你根本分不清手感变化是哪个参数造成的。正确做法是建一个GameSettings静态类把所有数值集中存放,每次只改一项,跑一局记录感受,再改下一项。

4.2 序列帧动画与 Animator:演示文件里最容易被忽略的贴图消耗

雷霆战机这类纵版射击,敌机爆炸、子弹拖尾、飞机发动机火焰都是用序列帧动画做的。序列帧本身不复杂,但演示文件里经常有坑:动画跑起来没执行、Animation Clip 的采样率过高导致包体增长。

贴图导入面板里,Texture Type 要设成 Sprite(2D and UI),Pixels Per Unit 用默认 100,Sprite Mode 选 Multiple 并在 Sprite Editor 里切片。多个序列帧最好打进同一个图集(Sprite Atlas),否则每一帧都是一次独立的 Draw Call,敌机一多帧率直接腰斩。Animator Controller 里只需要一个默认状态,把序列帧 Animation Clip 拖进 Motion 字段即可。

Animation Clip 的 Sample Rate 默认是 60,对像素风飞行射击游戏太高了。序列帧动画如果原图是 12 帧一秒,动画曲线实际用不到 60 个采样点,记得在 Animation 窗口把 Sample Rate 改成 12 或 15,包体体积会明显下降。Culling Mode 建议设成 Based On Renderers,这样屏幕外的敌机动画不会继续参与更新,对大量生成敌机的演示文件很有用。

4.3 屏幕适配与 Canvas 缩放:竖屏打飞机不能只改分辨率

演示文件在编辑器跑得正常,打包到手机上却出现敌机生成范围偏了、玩家被边缘挡住、UI 跑出屏幕,基本都栽在适配。核心规则只有一条:所有世界坐标边界都从相机动态算,不要写死常量。

Canvas 的缩放由 Canvas Scaler 控制。Unity 的 UI 坐标系和屏幕像素不同,竖屏飞行射击游戏通常把Scale Factor设为按高度适配(Match 值设成 1),这样不同宽度的屏幕都能保证 UI 层对齐。横屏打飞机则建议 Match 设 0.5 或 0.75,让宽高比对 UI 的影响都保留一半,具体值要靠真机预览微调。

世界坐标里的边界就用相机的ViewportToWorldPoint换算,相机 size 定了之后,这个函数会帮你把 0~1 的视口坐标转成世界坐标。代码里唯一要小心的是 z 值:2D 相机在 z=-10,物体在 z=0,调用时要传-mainCamera.transform.position.z作为 z 参数,否则得到的世界坐标是错的。

5. 雷霆战机演示文件最常见的 5 个翻车点:现象、原因与解决

5.1 TextMeshPro 文字变粉紫色方块

现象:打开场景后所有 TextMeshPro 文字变成粉紫色方块,UGUI 的普通 Text 正常。

原因:TextMeshPro 的字体资源(Font Asset)没有随演示文件一起打包,或者首次导入工程时没执行 TMP 资源导入。粉紫色方块表示材质引用了缺失的字体贴图。

解决:在菜单栏执行 Window > TextMeshPro > Import TMP Essential Resources,导入后重新指定每个文本组件的 Font Asset。如果导入后仍是紫块,检查 Font Asset 面板里的 Atlas 贴图是否为空,为空就点 Rebuild Atlas。

5.2 玩家角色完全不动但编辑器不报错

现象:运行后角色毫无反应,按方向键不动,但 Console 没有报错。

原因:工程安装了 Input System 包,并且 Player Settings 里 Active Input Handling 被设成 “Input System Package (New)”,而脚本用的是旧版Input.GetAxisRaw。旧接口被屏蔽后调用返回 0,不报错也不动。

解决:Edit > Project Settings > Player > Other Settings > Active Input Handling 改成 “Both”,重启编辑器。这是演示文件携带输入系统包时最常见的冲突,先看这个设置再排查脚本。

5.3 子弹经常从敌机身上穿过去

现象:子弹和敌机有时正常碰撞,有时直接穿过去,重复运行表现不一致。

原因:两个对象中至少一个缺少 Collider2D,或者子弹/敌机物理帧更新过于频繁导致穿透。更隐蔽的原因是两者碰撞体都处于静止状态,Unity 物理引擎为了提高性能会跳过静态-静态碰撞检测。

解决:检查子弹和敌机是否都有 Collider2D 和 Rigidbody2D(Kinematic 即可),至少一方要勾上 Is Trigger。玩家、子弹、敌机三层之间的 Layer Collision Matrix 也要确认勾选,Tag 不比 Layer 可靠,这里优先用 Layer。

5.4 跑半分钟后明显卡顿

现象:游戏运行一会儿开始掉帧,Profiler 的 GC Alloc 出现连续尖峰。

原因:最典型的对象池没做,每发子弹和每架敌机都在Instantiate/Destroy,连续生成一段时间后堆内存碎片化,触发频繁 GC。

解决:把子弹和敌机改成对象池管理,池子容量够用后基本不会再分配新对象。协程也要在场景切换时StopAllCoroutines(),否则生成逻辑在旧场景里继续运行,会叠加对象池外的泄漏。对象池不要只做一层,Remove(从场景消失)和 Destroy(真正删除)要分离,很多演示文件卡顿是因为回收对象时直接走 Destroy 把池子架空了。

5.5 换到窄屏设备后面玩家角色跑出屏幕

现象:在编辑器里一切正常,打包到窄屏或全面屏手机后,玩家角色能移动到屏幕外,边界判断完全失效。

原因:边界代码里用了Screen.width / 2或写死的常量世界坐标,没有考虑不同机型的宽高比。Screen.width是像素值不是世界单位,同一个宽高比在 orthographic 相机下世界坐标范围随着屏幕宽高比变化。

解决:所有边界判断统一改用Camera.main.ViewportToWorldPoint换算。再检查相机 Size 是否在窗口分辨率变化后保持稳定,如果有脚本改过相机尺寸也会导致边界漂移。UI 部分用Screen.safeArea限制在安全区内,不要直接铺满全屏。

6. 把演示文件改出自己的玩法:可视化边界、低成本扩展与性能验证

6.1 用 OnDrawGizmosSelected 把不可见的边界画出来

调试屏幕边界时最烦的是看不到边界在哪。Unity 提供了 Gizmos,能在 Scene 视图里画辅助线,强烈建议给生成器和玩家边界都画上。

#if UNITY_EDITOR using UnityEngine; public class SpawnBoundGizmo : MonoBehaviour { private void OnDrawGizmosSelected() { Camera cam = Camera.main; if (cam == null) return; Gizmos.color = Color.green; Vector3 bottomLeft = cam.ViewportToWorldPoint( new Vector3(0f, 0f, -cam.transform.position.z)); Vector3 topRight = cam.ViewportToWorldPoint( new Vector3(1f, 1f, -cam.transform.position.z)); Vector3 bl = bottomLeft; Vector3 br = new Vector3(topRight.x, bottomLeft.y, 0f); Vector3 tr = topRight; Vector3 tl = new Vector3(bottomLeft.x, topRight.y, 0f); Gizmos.DrawLine(bl, br); Gizmos.DrawLine(br, tr); Gizmos.DrawLine(tr, tl); Gizmos.DrawLine(tl, bl); } } #endif

挂到相机上,选中相机时 Scene 视图里就能看到完整边界框。OnDrawGizmosSelected只在选中对象时绘制,不常驻调用,对编辑器性能完全没有压力。这样调boundaryPadding时能看到留白到底有多少。

6.2 两个低成本扩展方向:概率掉道具与蛇形敌机

演示文件跑通后最值得做的扩展是把难度和奖励循环补上。概率掉道具最简单,在敌机回收前按 10%~15% 概率生成一个道具预制体,给道具一个PowerUp脚本,玩家碰到时触发对应效果,比如加一条子弹线。蛇形敌机则是在敌机移动脚本里加一个正弦波横移,让敌机不再只是垂直下落,这是改变节奏成本最低的玩法增强。

扩展需要新增的东西要改的参数
道具掉落道具预制体、道具脚本、新的 Tag 或 Layer掉落概率、道具持续时长
蛇形敌机敌机内置一个 sin 函数横向速度振幅 1~2、频率 1~3
三线射击玩家射击脚本里加两个偏移发射点发射间隔调成 0.2

6.3 用 Profiler 而不是“看着流畅”来验证改动

改完参数和代码,最后一步必须是开 Profiler 跑一把完整对局。Window > Analysis > Profiler,观察 CPU Usage 和 GC Alloc 两条曲线的最高点。流畅只是主观感受,真正要盯的是 GC Alloc 是否在持续上升——哪怕曲线没有尖峰,如果总量随游戏时间单调递增,对象池一定还有泄漏点。

我半年前自己搭一个类似演示工程时,最悔的是没早点把边界判断改成相机坐标系的写法,后来换了设备就反复出现“怎么又出屏了”的排查。从那以后,我拿到任何演示文件都先建一个参数集中管理脚本,再把 Gizmos 调试面板挂上,最后用 Profiler 记录一局性能基线。这套习惯比任何新功能都值钱。希望帮到你。

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

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

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

立即咨询