chore: initial commit
This commit is contained in:
517
Docs/Design/13_ProjectileSystem.md
Normal file
517
Docs/Design/13_ProjectileSystem.md
Normal file
@@ -0,0 +1,517 @@
|
||||
# 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.OnTriggerEnter2D(ParryHitBox 激活时):
|
||||
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: 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 的拼写错误风险:
|
||||
|
||||
```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)
|
||||
Reference in New Issue
Block a user