17 KiB
13 · 弹射物系统与对象池
命名空间
BaseGames.Combat(扩展)·BaseGames.Core(ObjectPool)
所属文档集 ← 返回索引 · 总览
依赖BaseGames.Core.Events·BaseGames.Combat(DamageInfo / HurtBox)·BaseGames.Parry(弹反流程)
目录
- 系统总览
- Projectile 组件
- 弹射物类型
- 可弹反弹射物(ParryableProjectile)
- GlobalObjectPool — 对象池架构
- ProjectileConfigSO
- BD_SpawnProjectile Task 实现
- 碰撞处理流程
- 跨场景 Pool 回收
- 事件频道
- 编辑器友好设计
1. 系统总览
弹射物系统解决以下问题:
| 问题 | 方案 |
|---|---|
| 敌人频繁射击产生大量 GameObject | GlobalObjectPool 对象池复用,避免 GC |
| 弹射物需携带完整伤害信息 | Projectile 持有 DamageInfo 结构体 |
| 弹反机制需要反弹弹射物 | ParryableProjectile 扩展类,OnParried() 反转方向 |
| 不同弹射物行为差异大 | 类型枚举 + 配置 SO,同一组件支持多种轨迹 |
| 场景切换时池对象泄漏 | SceneLoader 触发 Pool 回收(见 §9) |
2. Projectile 组件
Projectile 是所有弹射物的基类,挂载在弹射物 Prefab 根对象上:
namespace BaseGames.Combat
{
[RequireComponent(typeof(Rigidbody2D), typeof(Collider2D))]
public class Projectile : MonoBehaviour
{
// --- 运行时数据(从 ProjectileConfigSO 注入)---
[HideInInspector] public DamageInfo DamageInfo; // 由 BD_SpawnProjectile 注入
[HideInInspector] public Vector2 Direction; // 归一化发射方向
protected ProjectileConfigSO _config;
protected Rigidbody2D _rb;
protected float _aliveTimer;
// 初始化(对象池取出时调用,替代 Awake/Start)
public virtual void Initialize(ProjectileConfigSO config, DamageInfo damageInfo, Vector2 direction)
{
_config = config;
DamageInfo = damageInfo;
Direction = direction.normalized;
_aliveTimer = 0f;
_rb = GetComponent<Rigidbody2D>();
OnInitialized();
}
protected virtual void OnInitialized() { }
protected virtual void Update()
{
_aliveTimer += Time.deltaTime;
if (_aliveTimer >= _config.lifetime)
ReturnToPool();
}
protected void ReturnToPool()
=> GlobalObjectPool.Instance.Return(_config.poolKey, gameObject);
// 碰撞在子类或此处处理(见 §8)
protected virtual void OnTriggerEnter2D(Collider2D other) { }
}
}
3. 弹射物类型
通过 ProjectileConfig.moveType 枚举决定运动方式,同一个 Projectile 类支持三种轨迹:
3.1 LinearProjectile(直线)
public class LinearProjectile : Projectile
{
protected override void OnInitialized()
=> _rb.velocity = Direction * _config.speed;
// 速度由物理引擎维持,不需要每帧更新
}
用途:弓箭手普通箭矢、法师小型法球。
3.2 ArcProjectile(抛物线)
public class ArcProjectile : Projectile
{
protected override void OnInitialized()
{
// 给定角度和速度,分解为 X/Y 分量
float angle = _config.launchAngleDeg * Mathf.Deg2Rad;
_rb.velocity = new Vector2(
Direction.x * _config.speed * Mathf.Cos(angle),
_config.speed * Mathf.Sin(angle)
);
_rb.gravityScale = _config.gravityScale; // 受重力影响
}
}
用途:投石、毒液弹、炸弹投掷。
3.3 HomingProjectile(追踪)
【违规修复】 原实现使用
GameObject.FindGameObjectWithTag("Player"),违反零耦合原则(见00_Overview §2.1)。
已修正为通过TransformEventChannelSO事件频道注入玩家 Transform。
public class HomingProjectile : Projectile
{
[SerializeField] TransformEventChannelSO _onPlayerSpawned; // 注入依赖(SO 引用)
Transform _target;
void OnEnable()
=> _onPlayerSpawned.OnEventRaised += SetTarget;
void OnDisable()
=> _onPlayerSpawned.OnEventRaised -= SetTarget;
void SetTarget(Transform t) => _target = t;
protected override void OnInitialized()
{
// 若 _target 仍为 null(极少数情况:玩家未在事件频道广播时已存在)
// ProjectileManager 在发射时传入缓存值(见下方 ProjectileManager 说明)
_rb.velocity = Direction * _config.speed;
}
protected override void Update()
{
base.Update();
if (_target == null) return;
Vector2 toTarget = ((Vector2)_target.position - (Vector2)transform.position).normalized;
Vector2 newVel = Vector2.MoveTowards(
_rb.velocity.normalized, toTarget,
_config.homingStrength * Time.deltaTime) * _config.speed;
_rb.velocity = newVel;
}
}
ProjectileManager 辅助缓存(常驻 Persistent 场景,挂载于 GlobalObjectPool 同级):
public class ProjectileManager : MonoBehaviour
{
[SerializeField] TransformEventChannelSO _onPlayerSpawned;
Transform _playerTransform;
void OnEnable() => _onPlayerSpawned.OnEventRaised += t => _playerTransform = t;
void OnDisable() => _onPlayerSpawned.OnEventRaised -= t => _playerTransform = t;
/// <summary>
/// 发射追踪弹时,由发射方调用,传入已缓存的玩家 Transform。
/// 保证即使玩家在本帧出生,HomingProjectile 也能立即锁定目标。
/// </summary>
public void LaunchHoming(HomingProjectile proj, Transform origin, Vector2 direction)
{
proj.SetTarget(_playerTransform); // 主动注入缓存值
proj.Initialize(direction, origin);
}
public Transform PlayerTransform => _playerTransform;
}
PlayerController.Awake 中广播:
// PlayerController.cs
[SerializeField] TransformEventChannelSO _onPlayerSpawned;
void Awake()
{
// ... 其他初始化
_onPlayerSpawned.Raise(transform); // 广播自身 Transform,供 HomingProjectile 订阅
}
用途:追踪蜂群、Boss 阶段特殊弹。追踪强度 homingStrength(弧度/秒)由 SO 配置。
4. 可弹反弹射物(ParryableProjectile)
弹反机制的核心爽点之一:玩家成功弹反标有 CanBeParried 的弹射物后,弹射物反向飞回攻击者:
public class ParryableProjectile : LinearProjectile
{
bool _isParried = false;
// ParrySystem 在弹反判定成功时调用此方法
public void OnParried(Transform parryer)
{
if (_isParried) return; // 防止重复弹反
_isParried = true;
// 1. 方向反转
Direction = -Direction;
_rb.velocity = Direction * _config.speed * _config.parrySpeedMultiplier; // 弹反后速度可提升(默认 1.2×)
// 2. 更新 DamageInfo:攻击者变为玩家,伤害变为反弹伤害
DamageInfo = new DamageInfo(
attacker: parryer,
receiver: null, // 待碰撞时确定
damage: Mathf.RoundToInt(DamageInfo.Damage * _config.parryDamageMultiplier),
direction: Direction,
flags: DamageFlags.IsProjectile | DamageFlags.IsParried,
soulGain: 0
);
// 3. 切换碰撞层:弹射物现在只伤害敌人,不再伤害玩家
gameObject.layer = LayerMask.NameToLayer("PlayerProjectile");
// 4. 视觉/音效反馈
_onParryableProjectileParried.Raise(); // 订阅者:Feel MMF_Flash 金色闪光
}
}
ParrySystem 的弹反检测扩展
ParrySystem 已有弹反窗口检测(见 05_ParrySystem.md),在 OnParrySuccess 中增加弹射物检测:
// ParrySystem.OnTriggerEnter2D(ParryHitBox 激活时):
if (other.TryGetComponent<ParryableProjectile>(out var proj))
{
proj.OnParried(transform);
_onPlayerParrySuccess.Raise(/* DamageInfo */);
// 触发子弹时间、音效、特效(与普通弹反相同流程)
}
5. GlobalObjectPool — 对象池架构
GlobalObjectPool 常驻 Persistent 场景,管理全游戏的对象池:
namespace BaseGames.Core
{
public class GlobalObjectPool : MonoBehaviour
{
public static GlobalObjectPool Instance { get; private set; }
// Key: PoolKey(SO 资产引用,唯一标识), Value: 对象队列
readonly Dictionary<PoolKeySO, Queue<GameObject>> _pools = new();
// 从 Inspector 配置预热清单
[SerializeField] PoolPrewarmEntry[] _prewarmEntries;
async void Awake()
{
Instance = this;
foreach (var entry in _prewarmEntries)
await PrewarmAsync(entry.key, entry.prefabRef, entry.count);
}
async Task PrewarmAsync(PoolKeySO key, AssetReferenceGameObject prefabRef, int count)
{
// 通过 Addressables 加载 Prefab 资源(Handle 保留,池销毁时 Release)
var handle = Addressables.LoadAssetAsync<GameObject>(prefabRef);
await handle.Task;
var prefab = handle.Result;
_handles[key] = handle; // 记录 handle 以便最终 Release
if (!_pools.ContainsKey(key)) _pools[key] = new Queue<GameObject>();
for (int i = 0; i < count; i++)
{
var obj = Object.Instantiate(prefab, transform);
obj.SetActive(false);
_pools[key].Enqueue(obj);
}
}
public GameObject Get(PoolKeySO key, GameObject prefab)
{
if (!_pools.ContainsKey(key)) _pools[key] = new Queue<GameObject>();
var pool = _pools[key];
if (pool.Count > 0)
{
var obj = pool.Dequeue();
obj.SetActive(true);
return obj;
}
return Object.Instantiate(prefab); // 池耗尽时动态扩容
}
public void Return(PoolKeySO key, GameObject obj)
{
obj.SetActive(false);
obj.transform.SetParent(transform);
if (!_pools.ContainsKey(key)) _pools[key] = new Queue<GameObject>();
_pools[key].Enqueue(obj);
}
// 场景切换时回收指定 key 的所有活跃对象(见 §9)
public void RecycleAll(PoolKeySO key) { ... }
}
}
预热清单(Inspector 配置)
| PoolKey | Prefab | 预热数量 | 说明 |
|---|---|---|---|
Pool_Projectile_Arrow |
Arrow Prefab | 10 | 弓箭手弹射物 |
Pool_Projectile_MagicBall |
MagicBall Prefab | 8 | 法师弹射物 |
Pool_Collectible_GeoSmall |
GeoSmall Prefab | 20 | 小额 Geo |
Pool_Collectible_GeoMedium |
GeoMedium Prefab | 10 | 中额 Geo |
Pool_FX_HitSpark |
HitSpark Prefab | 15 | 命中粒子特效 |
Pool_UI_FloatingText |
FloatingText Prefab | 10 | 浮动伤害数字 |
PoolKeySO
使用 ScriptableObject 作为字典 Key,避免字符串 Key 的拼写错误风险:
[CreateAssetMenu(menuName = "Pool/PoolKey")]
public class PoolKeySO : ScriptableObject { }
// 每种对象类型创建一个 PoolKey.asset,在 Inspector 中拖拽引用
6. ProjectileConfigSO
每种弹射物类型一个 SO 资产:
[CreateAssetMenu(menuName = "Combat/ProjectileConfig")]
public class ProjectileConfigSO : ScriptableObject
{
[Header("基础属性")]
public float speed = 8f;
public float lifetime = 5f; // 超时自动回池
public int damage = 10;
public bool canBeParried = false;
[Header("弹射物类型")]
public ProjectileMoveType moveType = ProjectileMoveType.Linear;
[Header("Linear 参数")]
// (LinearProjectile 使用 speed 即可)
[Header("Arc 参数")]
public float launchAngleDeg = 45f;
public float gravityScale = 1f;
[Header("Homing 参数")]
public float homingStrength = 180f; // 度/秒
[Header("弹反参数")]
public float parrySpeedMultiplier = 1.2f;
public float parryDamageMultiplier = 1.5f;
[Header("对象池")]
public PoolKeySO poolKey;
public AssetReferenceGameObject prefabRef; // Addressable 引用,替代裸 GameObject
[Header("视觉")]
public AssetReferenceGameObject hitFXPrefabRef; // 命中时特效(Addressable,也走对象池)
}
资产路径:Assets/ScriptableObjects/Config/Combat/PC_{ProjectileName}.asset
7. BD_SpawnProjectile Task 实现
BehaviorDesigner Action Task,敌人 AI 中调用:
namespace BaseGames.Enemies.AI
{
public class BD_SpawnProjectile : ActionTask
{
[SerializeField] SharedTransform spawnPoint; // 发射点 Transform
[SerializeField] ProjectileConfigSO projectileConfig;
[SerializeField] SharedBool aimAtPlayer = true;
[SerializeField] SharedVector2 fixedDirection; // aimAtPlayer=false 时使用固定方向
EnemyStatsSO _stats;
public override void OnStart()
{
_stats = (GetDefaultGameObject().GetComponent<EnemyBase>()).Stats;
// 计算发射方向
Vector2 direction = aimAtPlayer.Value
? ((Vector2)(Owner.transform.position - spawnPoint.Value.position)).normalized * -1f
: fixedDirection.Value.normalized;
// 注:方向从敌人指向玩家
// 构建 DamageInfo
var dmgInfo = new DamageInfo(
attacker: Owner.transform,
receiver: null,
damage: projectileConfig.damage,
direction: direction,
flags: DamageFlags.IsProjectile,
soulGain: 0
);
// 从对象池取出弹射物
var obj = GlobalObjectPool.Instance.Get(projectileConfig.poolKey, projectileConfig.prefab);
obj.transform.position = spawnPoint.Value.position;
obj.GetComponent<Projectile>().Initialize(projectileConfig, dmgInfo, direction);
}
public override TaskStatus OnUpdate() => TaskStatus.Success;
}
}
8. 碰撞处理流程
Projectile.OnTriggerEnter2D 统一处理命中:
protected override void OnTriggerEnter2D(Collider2D other)
{
// 1. 检查碰撞层(使用 LayerMask 过滤)
if (!_config.hitLayers.Contains(other.gameObject.layer)) return;
// 2. 尝试获取 HurtBox
if (other.TryGetComponent<HurtBox>(out var hurtBox))
{
DamageInfo.Receiver = other.transform; // 填充接收方
hurtBox.TakeDamage(DamageInfo);
}
// 3. 命中特效(从对象池取出 HitFX,播放完毕后自动回池)
if (_config.hitFXPrefab != null)
{
var fx = GlobalObjectPool.Instance.Get(_config.hitFXPool, _config.hitFXPrefab);
fx.transform.position = transform.position;
// FX Prefab 自带 AutoReturn 组件,粒子播放完毕后调用 ReturnToPool()
}
// 4. 回池(弹射物消失)
// 特殊情况:穿透型弹射物(_config.penetrating=true)不立即回池
if (!_config.penetrating)
ReturnToPool();
}
碰撞层矩阵规则
| 弹射物层 | 可命中层 | 说明 |
|---|---|---|
EnemyProjectile |
Player,Ground |
敌人弹射物 |
PlayerProjectile |
Enemy,Ground |
弹反后的弹射物 |
9. 跨场景 Pool 回收
房间切换(Unload 旧场景)时,场景内弹射物的目标(敌人/玩家)已被销毁,需要回收所有活跃弹射物:
// SceneLoader.LoadRoom() 在 Unload 旧场景前:
GlobalObjectPool.Instance.RecycleAll(Pool_Projectile_Arrow);
GlobalObjectPool.Instance.RecycleAll(Pool_Projectile_MagicBall);
// ... 其他弹射物类型
// RecycleAll 实现:找到所有活跃的该类型对象,SetActive(false) 并入队
替代方案:Projectile 在 OnDisable() 中自动检测 return(若不是主动回池而是被场景卸载触发 OnDisable,则不操作,让场景卸载直接清理)。由于弹射物的 Pool 位于 Persistent 场景,不会随房间卸载,需要在 SceneLoader 中显式回收。
10. 事件频道
| 资产名 | 类型 | 用途 |
|---|---|---|
OnParryableProjectileParried.asset |
VoidEventChannelSO |
弹反弹射物时触发,启动 Feel 特效 |
11. 编辑器友好设计
ProjectileConfigSOCustom Inspector:预览弹射物轨迹(根据moveType在 Inspector 底部绘制轨迹示意图)GlobalObjectPoolInspector:实时显示每个 Pool Key 的队列大小 + 已借出数量- 弹射物 Prefab Gizmo:在 Scene View 显示 HitBox 碰撞体范围(黄色矩形)
BD_SpawnProjectile在 BT 节点上显示弹射物名称标签(自定义 BD NodeView)