Files
zeling_v2/Docs/Design/13_ProjectileSystem.md
2026-05-08 11:04:00 +08:00

518 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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直线
```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;
/// <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` 中广播**
```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.OnTriggerEnter2DParryHitBox 激活时):
if (other.TryGetComponent<ParryableProjectile>(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: PoolKeySO 资产引用,唯一标识), 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 的拼写错误风险:
```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<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` 统一处理命中:
```csharp
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 旧场景)时,场景内弹射物的目标(敌人/玩家)已被销毁,需要回收所有活跃弹射物:
```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