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

17 KiB
Raw Permalink Blame History

13 · 弹射物系统与对象池

命名空间 BaseGames.Combat(扩展)· BaseGames.CoreObjectPool
所属文档集 ← 返回索引 · 总览
依赖 BaseGames.Core.Events · BaseGames.CombatDamageInfo / HurtBox· BaseGames.Parry(弹反流程)


目录

  1. 系统总览
  2. Projectile 组件
  3. 弹射物类型
  4. 可弹反弹射物ParryableProjectile
  5. GlobalObjectPool — 对象池架构
  6. ProjectileConfigSO
  7. BD_SpawnProjectile Task 实现
  8. 碰撞处理流程
  9. 跨场景 Pool 回收
  10. 事件频道
  11. 编辑器友好设计

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.OnTriggerEnter2DParryHitBox 激活时):
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: 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 的拼写错误风险:

[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 PlayerGround 敌人弹射物
PlayerProjectile EnemyGround 弹反后的弹射物

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. 编辑器友好设计

  • ProjectileConfigSO Custom Inspector预览弹射物轨迹根据 moveType 在 Inspector 底部绘制轨迹示意图)
  • GlobalObjectPool Inspector实时显示每个 Pool Key 的队列大小 + 已借出数量
  • 弹射物 Prefab Gizmo在 Scene View 显示 HitBox 碰撞体范围(黄色矩形)
  • BD_SpawnProjectile 在 BT 节点上显示弹射物名称标签(自定义 BD NodeView