# 13 · 弹射物系统与对象池 > **命名空间** `BaseGames.Combat`(扩展)· `BaseGames.Core`(ObjectPool) > **所属文档集** [← 返回索引](./README.md) · [总览](./00_Overview.md) > **依赖** `BaseGames.Core.Events` · `BaseGames.Combat`(DamageInfo / HurtBox)· `BaseGames.Parry`(弹反流程) --- ## 目录 1. [系统总览](#1-系统总览) 2. [Projectile 组件](#2-projectile-组件) 3. [弹射物类型](#3-弹射物类型) 4. [可弹反弹射物(ParryableProjectile)](#4-可弹反弹射物parryableprojectile) 5. [GlobalObjectPool — 对象池架构](#5-globalobjectpool--对象池架构) 6. [ProjectileConfigSO](#6-projectileconfigso) 7. [BD_SpawnProjectile Task 实现](#7-bd_spawnprojectile-task-实现) 8. [碰撞处理流程](#8-碰撞处理流程) 9. [跨场景 Pool 回收](#9-跨场景-pool-回收) 10. [事件频道](#10-事件频道) 11. [编辑器友好设计](#11-编辑器友好设计) --- ## 1. 系统总览 弹射物系统解决以下问题: | 问题 | 方案 | |------|------| | 敌人频繁射击产生大量 GameObject | `GlobalObjectPool` 对象池复用,避免 GC | | 弹射物需携带完整伤害信息 | `Projectile` 持有 `DamageInfo` 结构体 | | 弹反机制需要反弹弹射物 | `ParryableProjectile` 扩展类,`OnParried()` 反转方向 | | 不同弹射物行为差异大 | 类型枚举 + 配置 SO,同一组件支持多种轨迹 | | 场景切换时池对象泄漏 | `SceneLoader` 触发 Pool 回收(见 §9)| --- ## 2. Projectile 组件 `Projectile` 是所有弹射物的基类,挂载在弹射物 Prefab 根对象上: ```csharp 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(); 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(直线) ```csharp public class LinearProjectile : Projectile { protected override void OnInitialized() => _rb.velocity = Direction * _config.speed; // 速度由物理引擎维持,不需要每帧更新 } ``` 用途:弓箭手普通箭矢、法师小型法球。 ### 3.2 ArcProjectile(抛物线) ```csharp 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。 ```csharp 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 同级): ```csharp public class ProjectileManager : MonoBehaviour { [SerializeField] TransformEventChannelSO _onPlayerSpawned; Transform _playerTransform; void OnEnable() => _onPlayerSpawned.OnEventRaised += t => _playerTransform = t; void OnDisable() => _onPlayerSpawned.OnEventRaised -= t => _playerTransform = t; /// /// 发射追踪弹时,由发射方调用,传入已缓存的玩家 Transform。 /// 保证即使玩家在本帧出生,HomingProjectile 也能立即锁定目标。 /// public void LaunchHoming(HomingProjectile proj, Transform origin, Vector2 direction) { proj.SetTarget(_playerTransform); // 主动注入缓存值 proj.Initialize(direction, origin); } public Transform PlayerTransform => _playerTransform; } ``` **`PlayerController.Awake` 中广播**: ```csharp // PlayerController.cs [SerializeField] TransformEventChannelSO _onPlayerSpawned; void Awake() { // ... 其他初始化 _onPlayerSpawned.Raise(transform); // 广播自身 Transform,供 HomingProjectile 订阅 } ``` 用途:追踪蜂群、Boss 阶段特殊弹。追踪强度 `homingStrength`(弧度/秒)由 SO 配置。 --- ## 4. 可弹反弹射物(ParryableProjectile) 弹反机制的核心爽点之一:玩家成功弹反标有 `CanBeParried` 的弹射物后,弹射物反向飞回攻击者: ```csharp 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](./05_ParrySystem.md)),在 `OnParrySuccess` 中增加弹射物检测: ```csharp // ParrySystem.OnTriggerEnter2D(ParryHitBox 激活时): if (other.TryGetComponent(out var proj)) { proj.OnParried(transform); _onPlayerParrySuccess.Raise(/* DamageInfo */); // 触发子弹时间、音效、特效(与普通弹反相同流程) } ``` --- ## 5. GlobalObjectPool — 对象池架构 `GlobalObjectPool` 常驻 Persistent 场景,管理全游戏的对象池: ```csharp namespace BaseGames.Core { public class GlobalObjectPool : MonoBehaviour { public static GlobalObjectPool Instance { get; private set; } // Key: PoolKey(SO 资产引用,唯一标识), Value: 对象队列 readonly Dictionary> _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(prefabRef); await handle.Task; var prefab = handle.Result; _handles[key] = handle; // 记录 handle 以便最终 Release if (!_pools.ContainsKey(key)) _pools[key] = new Queue(); 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(); 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(); _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 的拼写错误风险: ```csharp [CreateAssetMenu(menuName = "Pool/PoolKey")] public class PoolKeySO : ScriptableObject { } // 每种对象类型创建一个 PoolKey.asset,在 Inspector 中拖拽引用 ``` --- ## 6. ProjectileConfigSO 每种弹射物类型一个 SO 资产: ```csharp [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 中调用: ```csharp 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()).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().Initialize(projectileConfig, dmgInfo, direction); } public override TaskStatus OnUpdate() => TaskStatus.Success; } } ``` --- ## 8. 碰撞处理流程 `Projectile.OnTriggerEnter2D` 统一处理命中: ```csharp protected override void OnTriggerEnter2D(Collider2D other) { // 1. 检查碰撞层(使用 LayerMask 过滤) if (!_config.hitLayers.Contains(other.gameObject.layer)) return; // 2. 尝试获取 HurtBox if (other.TryGetComponent(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 旧场景)时,场景内弹射物的目标(敌人/玩家)已被销毁,需要回收所有活跃弹射物: ```csharp // 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. 编辑器友好设计 - `ProjectileConfigSO` Custom Inspector:预览弹射物轨迹(根据 `moveType` 在 Inspector 底部绘制轨迹示意图) - `GlobalObjectPool` Inspector:实时显示每个 Pool Key 的队列大小 + 已借出数量 - 弹射物 Prefab Gizmo:在 Scene View 显示 HitBox 碰撞体范围(黄色矩形) - `BD_SpawnProjectile` 在 BT 节点上显示弹射物名称标签(自定义 BD NodeView)