ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Unity2D射击游戏开发:雷霆战机项目结构、对象池与碰撞检测全解析

Unity2D射击游戏开发:雷霆战机项目结构、对象池与碰撞检测全解析 简介这是一份基于 Unity 引擎制作的 2D 游戏「雷霆战机」演示案例工程面向 Unity 初学者、独立游戏开发者及休闲射击游戏爱好者能够帮助读者快速了解横版/纵版战机射击类游戏的项目组织方式理解场景搭建、战机操控、敌机生成、子弹发射、碰撞检测与 UI 计分等核心功能的常规实现。压缩包为 rar 格式内含 1740 个文件除 dll 运行库、unity 场景、mat 材质、prefab 预制体、cs 脚本、png 贴图、hdr 环境贴图、mp3 音频外还包含 config/json 等配置数据以及 Unity 工程索引文件整体约为 455MB目录结构完整几乎可直接在 Unity 编辑器中打开运行。目前已有 513 人次学习查看。通过这套演示文件不仅可以对照学习完整 2D 游戏项目的资源分类与关卡配置思路还可借助预制体和脚本分析战机移动、敌我碰撞、计分逻辑等关键代码适合作为入门级 2D 游戏开发实操参考也便于在现有素材基础上进行功能扩展与效果调整。1. 雷霆战机 Unity2D 案例演示文件里藏着的完整套路做 Unity 两年多我拆过不少所谓「演示工程」大部分打开后要么脚本挂错、要么资源缺失。雷霆战机这个 Unity2D 案例反而让我刮目相看——它不是一个空壳 Demo而是把纵版射击游戏最核心的几块全部撑起来了玩家操控、子弹发射、敌机生成、碰撞检测、UI 计分与生命系统。如果你正在学 Unity2D 但苦于没有完整参考或者想快速搞懂一个射击游戏的项目结构这个演示文件是最直接的入手点。整个项目跑一遍下来我的判断是适合有三到六个月 Unity 基础的人复现。新手能照着捋清每个组件的作用熟手则可以提取里面对碰撞层和对象池的处理思路。后面我会从项目结构讲到具体脚本实现再给你列几条我实际跑的时候踩过的坑按步骤走基本不会翻车。2. 项目结构与场景组织先看懂文件再动手改2.1 演示文件里到底装了些什么打开项目文件夹后建议先别急着进场景把 Assets 目录结构过一遍。这个案例的目录划分很常规Scenes 文件夹放主游戏场景Scripts 按角色和系统拆开Prefabs 目录里是玩家飞机、敌机、子弹这些预设体Sprites 和 Audio 分别是素材和音效。这种结构的好处是你后续要替换素材或者扩展新敌机时不需要在场景里到处找对象。场景名称一般叫 Main 或者 GameScene加载后你会看到几个关键对象层级CanvasUI、Player、敌人管理器、背景滚动层。背景滚动通常有两种实现方式用 Quad 面片加滚动脚本或者用两张 Sprite 循环位移。雷霆战机案例用的多是 Sprite 循环位移方案因为它在正交相机下更稳定而且不会出现 UV 拉伸这类问题。在动手改之前我建议先检查一下项目设置里的 Input Manager。案例默认用的是水平轴向 Horizontal 来控制飞机左右移动如果你用的键盘是方向键不需要额外设置。后面如果要改成触屏操控这部分就要动。2.2 场景对象与层级关系为什么这么挂很多新手在搭建游戏场景时喜欢把所有对象平铺在 Hierarchy 面板里。这个案例里的层级是有讲究的背景、玩家、敌机管理器、UI 分属不同层级且每个层级下再挂子对象。这么做至少有两个直接好处——第一游戏运行时你能从 Hierarchy 面板一眼看出对象归属第二脚本里如果用 GameObject.Find 查找对象层级结构清晰可以极大减少查找出错的概率。拿玩家的挂载关系举例Player 对象下一般会有一个子物体专门用来发子弹这个发射点的位置决定了子弹从枪口生成而不是从飞机中心生成。这是很多新手容易忽略的细节但也是手感差异最大的细节。在案例里发射点的 Y 轴通常比玩家中心略高这样射击视觉上更真实。2.3 从场景到菜单场景切换与游戏状态严格来说雷霆战机这种街机风格的项目不会只有一个场景。案例演示文件里通常包含 Start 场景菜单和 Game 场景主玩法它们的切换用 Unity 自带的 SceneManager 就可以完成。要注意的是场景切换前需要把新场景加入到 Build Settings 的 Scenes in Build 列表中否则运行时场景跳转会直接报错。我会额外提醒一点如果菜单场景里面用了 EventSystem而游戏场景里也有相同的 EventSystem切换时容易产生重复实例。有些演示工程在这里偷懒没有处理但雷霆战机这个案例一般是把 EventSystem 保留在菜单场景游戏场景的 UI 按钮不多时不强求新建方便起见可以手动在 Game 场景里放一个但是要在 Awake 里做去重。常见写法是private void Awake() { if (FindObjectsOfTypeEventSystem().Length 1) { Destroy(gameObject); } }这段逻辑放在 EventSystem 挂载的脚本上作用是场景加载后检查全局是否已经有了另一个 EventSystem如果有就把自己销毁。原因是避免两个 EventSystem 抢输入焦点导致按钮点击偶尔失灵。你在案例里看到的 UI 按钮响应不稳定十有八九就是这个重复问题。3. 玩家控制与子弹系统关键参数和脚本逻辑拆解3.1 飞机操控的核心参数速度、边界、平滑度玩家控制的脚本在案例里通常命名为 PlayerController。核心参数一般有三个移动速度、边界范围、平滑系数。移动速度决定了飞机的响应速度太快会飘太慢会感觉拖泥带水。边界范围用于限制飞机不飞出屏幕案例里通常用边界值比如 -7 到 7配合 Clamp 函数来做限制。using UnityEngine; public class PlayerController : MonoBehaviour { public float speed 8f; public float minX -7f; public float maxX 7f; public float minY -4f; public float maxY 4f; void Update() { float h Input.GetAxis(Horizontal); float v Input.GetAxis(Vertical); Vector3 movement new Vector3(h, v, 0f); transform.position movement * speed * Time.deltaTime; transform.position new Vector3( Mathf.Clamp(transform.position.x, minX, maxX), Mathf.Clamp(transform.position.y, minY, maxY), 0f ); } }这段代码逻辑很直接读取水平和垂直轴向合成移动向量乘以速度再乘 Time.deltaTime 来保证帧率无关最后用 Clamp 把位置限制在有效范围内。这里的 minX、maxX、minY、maxY 需要根据你相机大小调整如果用的是正交相机且 orthographicSize 是 5那么屏幕宽高比决定边界值一般横屏是 -8 到 8竖屏是 -4 到 4。我一般会在 Start 方法里加一行调试日志看看场景运行后访问到的相机尺寸避免同样代码在不同分辨率设备上表现得完全不同。3.2 子弹发射对象池还是 Instantiate这是整个案例里最值得细看的部分。新手项目多半用 Instantiate 生成子弹子弹飞出屏幕后销毁。但这种方案在弹幕密集时会造成大量 GC 开销帧率会掉得很难看。雷霆战机案例之所以能维持稳定的射击表现是因为采用了对象池Object Pool机制。对象池的思路是第一次生成子弹时创建几个实例并隐藏射击时从池里取出一个激活子弹飞离屏幕或命中后隐藏回池里而不是直接销毁。这套机制在子弹数量多、生成频繁的场景下效果显著。using System.Collections.Generic; using UnityEngine; public class BulletPool : MonoBehaviour { public GameObject bulletPrefab; public int poolSize 20; private ListGameObject pool new ListGameObject(); void Start() { for (int i 0; i poolSize; i) { GameObject bullet Instantiate(bulletPrefab); bullet.SetActive(false); pool.Add(bullet); } } public GameObject GetBullet(Vector3 position, Quaternion rotation) { foreach (GameObject bullet in pool) { if (!bullet.activeInHierarchy) { bullet.transform.position position; bullet.transform.rotation rotation; bullet.SetActive(true); return bullet; } } return null; } }GetBullet 方法遍历对象池找到第一个未激活的对象重设位置和角度后激活返回。如果池子满了return null发射逻辑那边要处理空引用。我的做法是用 if 判断拿不到子弹就不发射而不是强行新建因为池的核心价值就是限制实例数量。参数方面poolSize 的值取决于你的射速和子弹存活时间。假设射速是每秒 5 发、子弹飞行到销毁需要 2 秒那么理论峰值是 10 发给 20 的池子就很充裕。案例里给的默认值如果你觉得射击时出现空隙那就是池子太小发射被吞了。3.3 子弹的飞行与销毁条件子弹脚本上一般有个 speed 参数Update 里朝指定方向移动。这里有个重要约定子弹不要用 transform.Translate 直接改位置除非你确定没有碰撞体。常规做法是void Update() { transform.Translate(Vector2.up * speed * Time.deltaTime); if (transform.position.y 6f) { gameObject.SetActive(false); } }这段逻辑是子弹向上飞飞出屏幕上边界后把自己隐藏回池。y 6f 这个阈值对应前面设置的相机边界如果你改了相机大小这里也要同步改。另一种做法是相机设置边界碰撞体让子弹通过触发器检测回收。案例里用的是阈值判断因为这样最直接少一个碰撞体就少一份性能开销。如果你想让子弹飞行时带一点偏移或者波动可以在 Update 里附加一个正弦扰动那需要一个 timer 变量累加 Time.deltaTime。不过这是后期手感调优不是主流程案例本体没有做太多花哨的弹道变化。4. 敌机生成与碰撞随机与判定背后的设计取舍4.1 敌机生成器的工作原理敌机生成在案例里通常用 SpawnManager 类控制。它的职责有两个定时生成敌机以及选择生成位置。定时可以用 InvokeRepeating也可以用协程。协程的写法更灵活可以在生成间隔里做更多控制IEnumerator SpawnRoutine() { while (true) { float randX Random.Range(-8f, 8f); Vector3 spawnPos new Vector3(randX, 6f, 0f); Instantiate(enemyPrefab, spawnPos, Quaternion.identity); float delay Random.Range(0.8f, 2.0f); yield return new WaitForSeconds(delay); } }每次循环先在 X 轴 -8 到 8 的范围里随机一个位置设定在屏幕上方然后生成敌机再等待一个随机间隔。这里的 delay 决定了游戏难度节奏。案例里如果你觉得敌人太密或太稀改不动其它代码时优先调这个 delay 的区间。Range 的下限越小敌人的密集程度越高。如果要把难度做成动态上升可以把 Random.Range 的区间随着游戏时间逐渐收窄。做法是维护一个 float 类型的 elapsedTime每帧累加在 delay 计算公式里让它参与运算。这属于进阶改造案例源码一般不直接给你写好但按照这个思路改是通的。4.2 敌机移动直线前进和常见变体敌机飞行的常见逻辑是直线向下移动碰上边界后隐藏回池如果你给敌机也做了对象池。雷霆战机案例里不同种类的敌机表现在移动速度上不同慢的飞机耐久高快的飞机机体小。这个设计让玩家在躲避时逐渐形成优先级判断是整个玩法可玩性的基础。敌机移动脚本里还需要注意碰撞体尺寸和视觉尺寸的匹配问题。Sprite 的视觉边缘和 Box Collider 的边界如果不同步就会出现子弹明明打到了飞机却弹开的情况。案例里这个对齐处理做得还算不错但你在自己复现时最好在 Scene 视图里开启 Gizmos逐帧检查碰撞体包裹范围。4.3 碰撞响应层与 Tag 的正确打开方式碰撞检测在 Unity2D 里有两种主流思路基于 Collider 加 Rigidbody2D 的物理碰撞以及基于 Trigger 加代码判断。案例里用的是 OnTriggerEnter2D 回调因为子弹、敌机、玩家三类对象都不需要真实的物理反弹只需要知道「谁碰到了谁」。private void OnTriggerEnter2D(Collider2D other) { if (other.CompareTag(Player)) { GameManager.Instance.UpdateLife(-1); Destroy(gameObject); } else if (other.CompareTag(Bullet)) { GameManager.Instance.AddScore(10); Destroy(gameObject); } }这段脚本挂载在敌机上当敌机碰到玩家或者子弹时触发相应逻辑。CompareTag 避免了字符串直接比较带来的性能浪费同时 Tag 在 Inspector 里配置比字符串硬编码更直观。案例里所有玩家的子弹统一挂 Tag 为 Bullet敌机挂 Enemy玩家飞机挂 Player这是分层清晰的关键。如果你把子弹的碰撞体设成了 Trigger那敌机的碰撞体也应该是 Trigger两边都是触发器时 OnTriggerEnter2D 才会被触发。如果一边是普通碰撞体一边是触发器那就是物理流程走了一半回调不会稳定出现。这个问题我排过很多次错最后发现是碰撞模式不一致——这是最容易忽略的坑。5. 避坑与常见问题演示文件跑不起来这五个坑先查5.1 场景里空空如也脚本全部丢失现象打开场景后Hierarchy 面板里只剩相机和灯光脚本和预制体全部为空控制台报 Missing Script。原因项目路径中包含中文字符或者 Unity 版本与案例制作版本差距过大导致脚本元数据.meta 文件关联失效。还有可能是直接从压缩包解压时文件完整度受损。解决先确认整个项目文件夹路径纯英文无空格重新解压后再次用 Unity Hub 打开。如果还是 Missing Script右键脚本重新关联或从 Prefab 里重新拖入组件。更稳妥的操作是在 Unity 中把 Assets 目录整个删除再还原让引擎重新生成 .meta 文件。5.2 飞机动不了按方向键完全没反应现象场景运行后点击 Play飞机定在原地键盘输入无效。原因案例代码在旧版本 Unity 里用的是 Input.GetAxis而新版本 Unity 在项目设置中把 Input Manager 的 Active Input Handling 改成了 Input System Package旧 API 会被忽略。解决进入 Project Settings → Player → Active Input Handling把选项从 Input System Package 改成 Both重启编辑器。这一步是 Unity 2021 之后的演示工程最常见脱坑操作。如果你用的是 Unity 2020 及更早版本通常没有这个选项直接检查 Horizontal 轴是否存在于 Input Manager 列表里。5.3 子弹不显示但控制台无报错现象点击发射后子弹没有生成也没有任何报错日志。原因子弹预制体可能被放在了场景里但 SetActive(false)发射脚本通过 GameObject.Find 找不到它或者对象池里没有可用对象且代码对 null 结果不做处理。解决查看 BulletPool 的 poolSize把池的数量调大检查子弹预制体在 Prefab 编辑器里是不是 active 状态。如果预制体本身是隐藏的Instantiate 出来后也是隐藏的要把初始化逻辑和激活逻辑分开。5.4 碰撞检测时灵时不灵现象子弹打到敌机偶尔穿过偶尔正常。原因子弹移动速度过快时如果每帧位移大于碰撞体厚度物理引擎会直接跳过碰撞检测出现穿透现象也就是常说的「隧道效应」。解决把子弹的碰撞体调大一些或者在子弹 Rigidbody2D 上把 Collision Detection 从 Discrete 改成 Continuous。另外不要用 Transform 直接改子弹位置来移动它最好用 Rigidbody2D.velocity 赋值让物理引擎完整参与。案例里如果用 Translate 移动速度高就会出现这个问题。5.5 背景不滚动或者滚动方向反了现象背景图原地不动或者往下滚而不是往上滚。原因背景滚动脚本里对 UV 偏移量的符号取反或者贴图纹理的 Wrap Mode 没有设为 Repeat导致偏移量即使变化也无法形成循环。解决检查背景 Sprite 的纹理导入设置把 Wrap Mode 改为 Repeat再看滚动脚本里 offset 的累加方向向上飞行的背景应该是 offset.y - speed * Time.deltaTime。调整后 Scene 视图里要切换到 Game 视图才能看到效果Scene 视图的渲染不一定实时反映 UV 偏移。6. 进阶改造把雷霆战机改成触屏操控的完整操作如果你打算把这个案例做成移动端可玩最需要改的就是输入部分。桌面端的 Input.GetAxis 在手机上完全没有输入来源需要换成虚拟摇杆或直接拖拽控制。我先说最简单的拖拽方案手指按到飞机上飞机跟随手指位置移动离开后飞机悬停。核心逻辑是用 Input.touchCount 和 GetTouch 方法取第一个触点的位置再用 Camera.ScreenToWorldPoint 把屏幕坐标转成世界坐标然后让飞机平滑移过去。这个方案的好处是不用引入任何 UI 组件跑在真机上体验也说得过去。更细腻的做法是再加上灵敏度曲线让飞机指哪打哪而不是像键盘一样匀速运动但基础方案已经能体现触屏的核心手感。void Update() { if (Input.touchCount 0) { Touch touch Input.GetTouch(0); Vector3 touchWorldPos Camera.main.ScreenToWorldPoint(touch.position); touchWorldPos.z 0f; transform.position Vector3.Lerp(transform.position, touchWorldPos, 10f * Time.deltaTime); } }这段代码里 Camera.main.ScreenToWorldPoint 是转换函数注意它要求传一个带 Z 值的 Vector3否则转换出来的 Z 坐标会变成相机位置操作后飞机会消失。我在这里吃过亏所以每次都会强制 touchWorldPos.z 0f。做完整套改造后我自己的习惯是每次调整操纵手感都在真机上跑一次。编辑器里模拟触摸的 Windows 触控环境和真机差异很大真机上的响应延迟、多点触控容忍度都直接影响体验。从那以后我每次拿到这种演示工程都会先在真机跑一遍移动端适配确认输入模块正常了再谈别的功能。触屏改造完成后整个雷霆战机案例就能从桌面演示变成可以装在手机上的完整小游戏希望帮到你。本文还有配套的精品资源点击获取
返回列表