ARTICLE DETAIL

资讯详情

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

Unity炮弹发射系统实战:从C#脚本到对象池完整实现

Unity炮弹发射系统实战:从C#脚本到对象池完整实现 在 Unity 游戏开发中“炮弹发射”是一个高频需求射击游戏、塔防、RTS 甚至物理模拟 Demo 中都会用到。很多刚入门的朋友在搜索引擎里输入“unity 蓝图炮弹发射”会发现两个名词混在了一起Unity 和蓝图。这里要先做一个重要澄清——蓝图Blueprint并不是 Unity 的功能而是 Unreal Engine虚幻引擎的可视化脚本系统。Unity 常见的“炮弹发射”实现方式是用 C# 脚本完成配合预制体、刚体、碰撞器与粒子特效。本文会围绕 Unity 引擎完整拆解一套炮弹发射系统的实现流程从场景设计、预制体制作到发射脚本、炮弹飞行、命中反馈、UI 显示和性能优化最后给出常见问题排查清单。无论你是零基础新手还是想优化现有实现的中级开发者都可以直接对照本文操作。1. “Unity 蓝图”到底是什么1.1 蓝图是 UE 的概念不是 Unity 的概念Blueprint蓝图是 Unreal Engine 的官方可视化脚本系统开发者无需编写 C可以通过连接节点来编写游戏逻辑。它在 UE 中的定位相当于“可视化版 C”擅长快速搭建玩法逻辑。Unity 没有完全对等的通用可视化脚本系统。Unity 官方提供的可视化能力分散在不同工具中Unity 可视化工具用途Shader Graph可视化编写 ShaderVFX Graph可视化制作粒子与特效Animator / State Machine可视化制作动画状态机Timeline可视化编排过场与时间轴Bolt已并入 Unity Visual Scripting通用可视化脚本类似蓝图但市面使用率远低于 C#所以“Unity 蓝图炮弹发射”这个说法本质上是把两个引擎的术语混用了。实际开发中Unity 炮弹发射最常见的做法是用 C# 脚本控制发射逻辑用预制体Prefab作为炮弹模板用 Rigidbody 物理系统让炮弹飞起来用 Collider 和 Tag 判断命中如果你之前看过 UE 的蓝图层层叠叠的节点教程那么本文的 C# 版本就是它在 Unity 里的等价实现。1.2 Unity 炮弹发射体系的组成一个完整的 Unity 炮弹发射模块无论游戏类型如何都包含这几层发射者炮塔、角色、玩家手指点击位置负责提供发射点与朝向。炮弹实体预制体上面挂着 Mesh/模型、Collider、Rigidbody、炮弹行为脚本。受力逻辑通过 velocity 或 AddForce 给炮弹初速度。命中判定碰撞检测或射线检测判断是否击中目标。反馈表现命中特效、爆炸特效、音效、伤害数值。生命周期炮弹超出距离或存活时间后销毁或回收。后面所有代码和配置都是围绕这六层展开。2. 环境准备与项目结构2.1 环境说明本文示例代码使用 Unity 的稳定 LTS 版本编写。如果你使用的是 Unity 2021 LTS、2022 LTS 或更新版本API 基本一致可以直接复制运行。如果你的项目是 2018 或 2019 的旧版本需要注意 Rigidbody 相关 API 没有差异但 Input 输入系统可能不同。示例项目结构Assets/ ├── Scenes/ │ └── Main.unity ├── Scripts/ │ ├── CannonController.cs │ ├── Projectile.cs │ └── Target.cs ├── Prefabs/ │ ├── CannonBall.prefab │ └── MuzzleFlash.prefab └── Materials/ ├── CannonMat.mat └── TargetMat.mat不需要引入任何第三方插件也不需要付费资源纯 Unity 内置组件即可完成。2.2 场景搭建思路我们创建一个最简单的“移动炮台射击目标”场景场景中放一个圆柱体作为炮管或者任意立方体作为简易炮台。炮管前方作为炮弹发射点FirePoint。炮管上挂 CannonController 脚本。场景中放一个 Cube 作为目标挂 Target 脚本。创建 Sphere 炮弹预制体挂 Projectile 脚本和 Rigidbody。看到这里你会发现Unity 的炮弹发射比 UE 蓝图节点看起来更接近传统编程但底层逻辑是一样的生成对象、赋予方向、赋予速度、检测碰撞。3. 炮弹发射的核心原理拆解3.1 发射的三步关键动作炮弹发射在代码层面只有三步第一步根据炮弹模板创建对象。Unity 中通过Instantiate创建一个预制体实例。GameObject go Instantiate(projectilePrefab, firePoint.position, firePoint.rotation);这里用了firePoint.position和firePoint.rotation目的是让炮弹出生在炮口并且朝向和炮口一致。第二步获取炮弹上的刚体组件并给予初速度。Rigidbody rb go.GetComponentRigidbody(); rb.velocity go.transform.forward * speed;transform.forward是物体自身的蓝色轴Z 轴方向。只要预制体的 Z 轴与模型头部一致炮弹就会沿着炮口方向飞出。第三步炮弹飞行中处理碰撞和生命周期。这一步在炮弹自身的 Projectile 脚本中完成后面会展开。3.2 “物体速度怎么获取”的常见疑问很多 Unity 新手在论坛问“unity 物体速度怎么获取”。对于炮弹系统这个问题的答案很简单直接读取刚体的velocity属性。Rigidbody rb GetComponentRigidbody(); Vector3 currentVelocity rb.velocity; float speed currentVelocity.magnitude;velocity是刚体当前的线速度向量magnitude是速度大小。在炮弹飞行过程中如果炮弹没有受到空气阻力速度基本保持不变如果加了重力垂直方向速度会变化magnitude也会跟着变化。3.3 velocity 与 AddForce 的区别给炮弹施加初速度有两种常见写法rb.velocity transform.forward * speed;这是直接设置速度炮弹从第一帧就拥有指定速度。适合子弹、炮弹、箭矢这类“出膛即有初速度”的物体。rb.AddForce(transform.forward * force, ForceMode.Impulse);这是给刚体加冲量实际速度会受刚体质量影响并且从 0 开始加速。适合投掷物、载具推动等需要模拟“受力累积”的场景。炮弹发射优先推荐velocity因为逻辑直白、可控性强不受质量影响也不会出现炮弹在空中慢慢加速的尴尬表现。如果你需要炮弹在空中受重力影响那就在 Projectile 脚本里把刚体的useGravity设为 true同时保留初始水平速度。3.4 对象池思想与内存问题炮弹发射最常见的性能问题是频繁Instantiate和Destroy。每创建一个 GameObject 就会申请一次内存每销毁一个 GameObject 又会触发一次 GC。FPS 类游戏一秒内可能有几十发炮弹如果全部创建再销毁帧率很容易被拖垮。业界通用做法是对象池Object Pool预先创建一批炮弹实例发射时从池中取一个“空闲”炮弹移动并激活炮弹命中或超时后不销毁而是停用并放回池中。后面在最佳实践章节会给出简化版实现。4. 完整实战案例从零搭建 Unity 炮弹发射系统4.1 创建场景与物体打开 Unity 新建 3D 项目然后按下面步骤操作在 Hierarchy 中新建一个空物体命名为Cannon。在 Cannon 下创建一个 Cylinder作为炮管旋转 90 度让圆柱轴线朝向场景前方。在 Cannon 下创建一个空物体命名FirePoint放在炮口位置作为发射点。在场景中创建一个 Cube命名为Target放到炮管前方 10 米左右。不要用默认的 Main Camera 位置建议把摄像机放到炮管斜后方方便观察炮弹飞行。4.2 创建炮弹预制体在 Hierarchy 中创建 Sphere命名CannonBall。给 Sphere 添加Rigidbody组件取消Use Gravity勾选炮弹飞行不需要重力除非你要做抛物线。给 Sphere 添加Sphere Collider默认即可。将 Sphere 拖到 Project 窗口的Prefabs文件夹中生成预制体。删除 Hierarchy 中原场景里的 Sphere。炮弹预制体上不挂发射逻辑只挂最终会编写好的 Projectile 脚本。4.3 编写发射脚本 CannonController.cs// 文件路径Assets/Scripts/CannonController.cs using UnityEngine; public class CannonController : MonoBehaviour { [Header(发射点)] public Transform firePoint; [Header(炮弹预制体)] public GameObject projectilePrefab; [Header(炮弹出膛速度)] public float projectileSpeed 30f; [Header(发射间隔秒)] public float fireInterval 0.3f; [Header(炮弹存活时间秒)] public float projectileLife 5f; private float lastFireTime; private void Update() { // 按下鼠标左键并判断是否过了发射冷却时间 if (Input.GetButtonDown(Fire1) Time.time - lastFireTime fireInterval) { Fire(); } } private void Fire() { if (projectilePrefab null || firePoint null) { Debug.LogWarning(CannonController 缺少炮弹预制体或发射点); return; } // 在炮口位置生成炮弹朝向跟随炮口旋转 GameObject go Instantiate(projectilePrefab, firePoint.position, firePoint.rotation); // 让炮弹自己开始飞行 Projectile projectile go.GetComponentProjectile(); if (projectile ! null) { projectile.Shoot(projectileSpeed, projectileLife); } lastFireTime Time.time; } }脚本逻辑很清晰Update中监听鼠标左键。Time.time是游戏运行的累计时间通过lastFireTime控制最小发射间隔。Fire中实例化炮弹然后调用炮弹自身的Shoot方法。这里用一个技巧把生成和发射“解耦”了炮台只负责“造出炮弹并交给炮弹”炮弹怎么飞、怎么消失由炮弹自己管后续扩展多弹种、多发射节奏会容易很多。4.4 编写炮弹行为脚本 Projectile.cs// 文件路径Assets/Scripts/Projectile.cs using UnityEngine; public class Projectile : MonoBehaviour { private float speed; private float life 5f; private float aliveTime; private Rigidbody rb; private void Awake() { rb GetComponentRigidbody(); if (rb null) { Debug.LogError(炮弹缺少 Rigidbody 组件); } } private void Update() { // 累计存活时间超过生命周期后自动销毁 aliveTime Time.deltaTime; if (aliveTime life) { DestroySelf(); return; } } public void Shoot(float speedValue, float lifeTime) { speed speedValue; life lifeTime; if (rb ! null) { // 让炮弹获得初速度 rb.velocity transform.forward * speed; } } private void OnTriggerEnter(Collider other) { // 这里只与挂在 Target 标签下的目标交互 if (other.CompareTag(Target)) { Target target other.GetComponentTarget(); if (target ! null) { target.Hit(10); } DestroySelf(); } } private void DestroySelf() { Destroy(gameObject); } }关键点Awake中提前获取 Rigidbody避免运行时重复查找。Shoot是公共方法由发射者调用。炮弹在获得速度之前不会移动。OnTriggerEnter是触发器碰撞事件。炮弹上的 Collider 需要勾选Is Trigger或者与目标使用普通碰撞。实际项目中建议使用触发器减少非必要物理反应。DestroySelf统一处理销毁后续如果要接入对象池只需要修改这一个方法。注意 Collider 的 Trigger 设置如果你已经在炮弹 Sphere Collider 上勾选了Is Trigger那么炮弹不会阻挡其他物体只会触发进入事件。这是炮弹最常见的配置方式。4.5 编写目标脚本 Target.cs// 文件路径Assets/Scripts/Target.cs using UnityEngine; public class Target : MonoBehaviour { [Header(目标血量)] public int maxHp 30; private int hp; private void Start() { hp maxHp; } public void Hit(int damage) { hp - damage; Debug.Log(目标受到 damage 点伤害剩余 HP hp); if (hp 0) { Die(); } } private void Die() { Debug.Log(目标被摧毁); Destroy(gameObject); } }场景中选中 Target 立方体在 Inspector 中添加Tag为Target并把 Target.cs 挂上去。4.6 挂载脚本并运行最后一个步骤是把脚本挂到场景物体上在 Hierarchy 中选中Cannon。添加CannonController脚本组件。把FirePointCannon 下的子物体拖到Fire Point字段。把CannonBall预制体拖到Projectile Prefab字段。在 Hierarchy 中选中 CannonBall 预制体先在场景中创建一个 CannonBall 然后把代码挂上去再拖进 Prefabs或者直接在预制体上添加脚本。完成之后点击 Play鼠标左键点击应该能看到炮弹从炮口飞出命中目标后目标被销毁。4.7 预期运行结果正常情况下的行为表现每次左键按下炮口生成一颗炮弹。炮弹沿炮口 Z 轴方向直线飞行速度约为 30。炮弹命中 Target 标签的物体时目标扣除 10 点 HP。HP 归零后目标销毁。炮弹无论是否命中5 秒后自动销毁。如果炮弹没有飞出去或者方向不对参考第六章排查。5. 功能增强命中特效、音效、UI 与抛物线炮弹基础炮弹完成了接下来让它更像一个真正的游戏系统。5.1 命中粒子特效在炮弹命中目标的位置生成爆炸特效是最常见的效果。先在场景中创建一个 Particle System调整成爆炸形状然后保存为预制体ExplosionPrefab。在Projectile.cs中增加一个公开字段和爆炸方法public GameObject explosionPrefab; private void OnTriggerEnter(Collider other) { if (other.CompareTag(Target)) { Target target other.GetComponentTarget(); if (target ! null) { target.Hit(10); } PlayExplosion(); DestroySelf(); } } private void PlayExplosion() { if (explosionPrefab ! null) { Instantiate(explosionPrefab, transform.position, Quaternion.identity); } }这里有一个性能坑如果爆炸特效预制体在主循环中不断创建和销毁会产生大量内存碎片时间久了就会出现“粒子特效内存泄露”类问题。有效的办法是在特效预制体 Particle System 上勾选Stop Action为Destroy让粒子播放完自动销毁自身。或者把特效也放入对象池。5.2 播放射击音效和命中音效在CannonController.cs中增加 AudioSource[Header(音效)] public AudioSource audioSource; public AudioClip fireClip; private void Fire() { // ...之前的生成逻辑 if (audioSource ! null fireClip ! null) { audioSource.PlayOneShot(fireClip); } }在Target.cs的Hit中播放受击音效做法类似。PlayOneShot适合低频的即时音效多音效叠加时不会互相打断。5.3 炮弹数量与 UI 显示发射有冷却时间限制了但弹药数也要限制。在CannonController.cs中增加弹药数[Header(弹药)] public int maxAmmo 30; private int currentAmmo; private void Start() { currentAmmo maxAmmo; } private void Fire() { if (currentAmmo 0) { Debug.Log(弹药耗尽); return; } // ...生成炮弹 currentAmmo--; UpdateAmmoUI(); }这里UpdateAmmoUI需要关联 UGUI 的 Text 组件。如果不想让脚本之间硬耦合可以定义事件或者直接通过单例引用 UI但在入门阶段最简单的方式是让 CannonController 直接持有 Text 引用。5.4 如何实现抛物线炮弹很多游戏不满足于直线炮弹要实现抛物线的“炮弹抛射”。有两种方案第一种开启炮弹刚体的重力。rb.useGravity true; rb.velocity transform.forward * speed;炮弹会边前进边下落形成抛物线。但初速度方向和大小需要人工试验不然很容易砸地板。第二种使用物理公式计算发射角度。对于斜上抛运动水平距离d最大高度h发射速度v重力加速度g可以算出发射角float angle Mathf.Atan2(v * v - Mathf.Sqrt(Mathf.Pow(v, 4) - g * (g * d * d 2 * h * v * v)), g * d) * Mathf.Rad2Deg;这段公式只需要在需要精准落点投掷时使用一般游戏里直接调参数更实用。还有一个更轻量的方式不修改炮弹物理逻辑而是用动画曲线或者Vector3.Lerp做插值运动。但这样会失去物理碰撞感只适合表现层面。6. 常见问题与排查思路实际操作中炮弹系统最容易遇到下面几类问题。问题现象常见原因解决思路炮弹没有飞出去炮弹预制体缺少 Rigidbody或 velocity 设置后又被 Update 清零检查 Rigidbody 是否挂载检查 Projectile.Shoot 是否被调用炮弹方向不对预制体模型 Z 轴朝下或偏移在模型导入设置里调整轴向让炮弹 Z 轴朝前或者用 firePoint.forward 手动定向炮弹太快导致穿透子弹速度过高物理步长内碰撞体位移过大修改 Rigidbody 的 Collision Detection 为 Continuous / Continuous Dynamic或改用射线检测炮弹卡在原地不动Rigidbody 约束继承了父物体位置确认炮口生成时使用的是世界坐标 firePoint.position 而不是 localPosition命中后目标没反应炮弹 Collider 和 Target Collider 没有满足碰撞条件Tag 不一致检查 Collider 是否触发、Target 是否挂了 Target 脚本、Tag 是否命名为 Target发射间隔无效lastFireTime 没更新或 Time.timeScale 0检查冷却时间写法不要把 Time.time 和 Time.realtimeSinceStartup 混用粒子特效播放后残留特效没有停止和销毁机制设置 Particle System 的 Stop Action 为 Destroy或使用对象池频繁 Instantiate 导致卡顿每颗炮弹都实时创建销毁引入对象池6.1 炮弹穿透的细节排查如果炮弹速度达到 30 以上Unity 默认的Discrete离散碰撞检测可能在一次物理更新中直接穿过了目标也就是“相穿”。这种情况下选中炮弹预制体找到 Rigidbody 组件。把Collision Detection从Discrete改为Continuous或Continuous Dynamic。但这会增加物理开销所以在炮弹不多的时候使用没压力如果大规模炮弹齐射建议改用射线检测来判定命中。射线检测方案示例Ray ray new Ray(firePoint.position, firePoint.forward); if (Physics.Raycast(ray, out RaycastHit hit, 500f)) { Target target hit.collider.GetComponentTarget(); if (target ! null) { target.Hit(10); } }适合激光类、枪弹类省去炮弹实体飞行过程。6.2 方向不一致的处理炮管模型来自不同建模工具时轴向经常不统一。Unity 规则是模型正面通常朝向 Z 轴正方向。如果是平躺的行为比如圆柱体炮管导入后可能旋转了 90 度。不要试图在代码里旋转炮弹正确的做法是在 Project 中选中模型资源。在 Model 导入设置的Rotation中调整数值让默认朝向是 Z 轴正方向。使用Fix Now按钮更新模型。这样所有场景中的炮弹发射点都会保持一致方向。7. 最佳实践与工程建议7.1 使用对象池优化频繁发射对象池是这个系统最重要的优化手段。下面给出一个简化版对象池实现思路可以直接替换Instantiate与Destroy。// 文件路径Assets/Scripts/BulletPool.cs using System.Collections.Generic; using UnityEngine; public class BulletPool : MonoBehaviour { public GameObject bulletPrefab; public int poolSize 20; private QueueGameObject pool new QueueGameObject(); private void Start() { for (int i 0; i poolSize; i) { GameObject go Instantiate(bulletPrefab); go.SetActive(false); pool.Enqueue(go); } } public GameObject Get() { if (pool.Count 0) { // 池不够用时扩容 GameObject extra Instantiate(bulletPrefab); return extra; } GameObject obj pool.Dequeue(); obj.SetActive(true); return obj; } public void Return(GameObject obj) { obj.SetActive(false); pool.Enqueue(obj); } }使用对象池后炮弹的DestroySelf改成private BulletPool pool; public void SetPool(BulletPool bulletPool) { pool bulletPool; } private void DestroySelf() { if (pool ! null) { pool.Return(gameObject); } else { Destroy(gameObject); } }池化后的效果是内存中始终存在一批炮弹对象动态内存分配大幅减少。在移动端、低端机上差距尤其明显。7.2 合理使用物理图层复杂场景中炮弹不应该命中所有物体。比如玩家自己的炮弹不应该打自己也不应该打队友。Unity 的 Layer Collision Matrix 可以解决。在 Tags and Layers 中新增Player、Enemy、Bullet等层。在 Project Settings - Physics 中设置碰撞矩阵。在代码中通过LayerMask控制射线检测。然后给炮弹只保留需要碰撞的层LayerMask targetMask; private void OnTriggerEnter(Collider other) { if (targetMask (targetMask | (1 other.gameObject.layer))) { // 命中合法目标 } }这样比只依赖 Tag 更安全、更高效不会在几十个 Collider 中逐一比较字符串标签。7.3 数据驱动与 ScriptableObject炮弹速度、伤害、生命周期、特效、音效这些参数如果都写在脚本里改起来很痛苦。推荐使用 ScriptableObject 把炮弹配置抽离出来。// 文件路径Assets/Scripts/ProjectileConfig.cs using UnityEngine; [CreateAssetMenu(fileName ProjectileConfig, menuName 射击系统/炮弹配置)] public class ProjectileConfig : ScriptableObject { public float speed 30f; public float lifeTime 5f; public int damage 10; public GameObject explosionPrefab; public AudioClip hitClip; }CannonController 里维护一个配置列表按弹药类型选择不同配置后续新增炮弹类型只需要新增一个配置资产不需要改代码。7.4 命中逻辑用接口隔离当炮弹命中目标时目标可能是小型靶子、Boss、箱子、玩家。不要只给 Target 类加逻辑更优雅的做法是定义 IDamageable 接口。// 文件路径Assets/Scripts/IDamageable.cs public interface IDamageable { void TakeDamage(int damage); }然后任何可受击物体都实现这个接口炮弹只依赖接口IDamageable damageable other.GetComponentIDamageable(); if (damageable ! null) { damageable.TakeDamage(10); }这样 Target、Enemy、Player 都可以自然接入炮弹系统。7.5 关于粒子内存与特效管理Unity 中“粒子特效内存泄露”这个问题的根因通常不是引擎 bug而是开发者创建了大量粒子对象后没有正确销毁或者使用了协程延迟销毁却没有触发。几个建议所有一次性爆炸特效设置Stop Action Destroy。所有持续循环特效火焰、烟雾使用对象池。不要在 Update 中反复创建新 ParticleSystem。特效完成后主动ParticleSystem.Stop(true, ParticleSystemStopBehavior.StopEmitting)然后销毁或回收。这是所有炮弹系统中隐藏的内存风险区重视它比优化代码更实际。7.6 网络同步预留如果未来要改造成联机射击游戏炮弹系统要提前考虑网络同步。一般做法是客户端表现层只管本地飞行动画。发射事件通过 RPC 广播。命中判定由服务端执行。炮弹运动的Rigidbody.velocity通过网络状态同步。这部分在单机教程中先不展开但设计脚本时尽量把“发射指令”“炮弹表现”“伤害结算”分开后续接入 Mirror、Photon 等网络框架时会轻松很多。8. 写给新手的额外提醒炮弹系统是游戏物理、对象管理、UI、音频、粒子等多个模块的交叉点很适合作为 Unity 进阶段练手项目。如果你是从零开始建议按以下顺序动手先复制本文的 CannonController、Projectile、Target 三个脚本跑通直线炮弹。加入粒子特效与音效看懂预制体和引用关系。加入 UI 弹药计数熟悉 UGUI 和代码联动。修改 Rigidbody 参数体验重力抛物线效果。尝试写一个最简单对象池观察 Profiler 中 GC Alloc 的变化。最后把脚本中的硬编码参数抽成 ScriptableObject。每一步都动手运行不要只读代码。Unity 的编辑器里物理参数改变后运行效果差异非常大。如果你遇到炮弹发不出来或者方向不对先把预制体和脚本挂载关系慢慢查一遍90% 的新手问题都出在 Inspector 中的字段没有拖拽赋值上。如果这篇文章对你有帮助建议收藏备用后续扩展伤害计算、多炮管、追踪弹、网络同步时可以直接回来参考。动手写代码才是提升 Unity 水平最快的方式。
返回列表