Files
zeling_v2/Docs/Architecture/20_ShieldModule.md
2026-05-12 15:34:08 +08:00

13 KiB
Raw Permalink Blame History

20 · 护盾模块Shield Module

命名空间 BaseGames.Combat
程序集 BaseGames.CombatAssets/Scripts/Combat/
依赖 BaseGames.Core.Events · BaseGames.CombatDamageInfo · HurtBox· BaseGames.UIHUDController
Design 来源 30_ShieldMechanicsSystem


目录

  1. 模块职责
  2. 伤害管道修正
  3. ShieldConfigSO
  4. ShieldComponent
  5. IShieldable 接口
  6. 护盾恢复机制
  7. 护盾 UI 集成
  8. 弹反集成P1
  9. SaveData 集成
  10. 事件频道

1. 模块职责

护盾是独立于玩家 HP 之外的第二道防御层

伤害输入(来自 HurtBox
  │
  ▼
ShieldComponent.AbsorbDamage(incomingDamage)
  ├─ 护盾耐久 > 0 且未破碎
  │   ├─ 吸收 = damage × DamageAbsorptionRatio
  │   ├─ 扣除护盾耐久
  │   ├─ 耐久归零 → 触发护盾破碎EVT_ShieldBroken
  │   └─ 返回穿透量 → HurtBox 直接调用 _damageable.TakeDamage(passInfo)
  │
  └─ 护盾已破碎或耐久 = 0
      └─ HurtBox 走原始伤害流程(直接 TakeDamage

零耦合ShieldComponent.AbsorbDamage() 只返回穿透量,不持有 PlayerStats 引用。穿透伤害由 HurtBox.ReceiveDamage() 负责转交给 IDamageable.TakeDamage()(见 §2


2. 伤害管道修正

HurtBox.ReceiveDamage() 需要在调用 IDamageable.TakeDamage 之前检查护盾:

// HurtBox.cs修改 06_CombatModule §2 的实现)
public void ReceiveDamage(DamageInfo info)
{
    if (!_isActive) return;
    if (_shieldable != null && _shieldable.HasShield)
    {
        // 护盾优先拦截AbsorbDamage 返回穿透伤害剧量
        int passThrough = _shieldable.AbsorbDamage(info.Amount);
        if (passThrough > 0)
        {
            var passInfo = info;
            passInfo.Amount = passThrough;
            _damageable?.TakeDamage(passInfo);
        }
        // ShieldComponent 内部已通过事件频道更新 ShieldBarUI
        return;
    }
    // 无护盾或已穿透,走原始流程
    _damageable?.TakeDamage(info);
    _onDamageDealt.Raise(info);  // EVT_DamageDealtAnalyticsManager
    _onHitConfirmed.Raise(new HitInfo { DamageInfo = info, HitPoint = transform.position });
}

PlayerController 在 Awake 时将 ShieldComponent 注入 HurtBox._shieldable

// PlayerController.Awake()
_hurtBox.SetShieldable(_shieldComponent);

3. ShieldConfigSO

namespace BaseGames.Combat
{
    [CreateAssetMenu(menuName = "Player/ShieldConfig")]
    public class ShieldConfigSO : ScriptableObject
    {
        [Header("耐久")]
        [Min(1)]
        public int   MaxShieldHP            = 60;
        [Range(0f, 1f)]
        public float DamageAbsorptionRatio  = 1.0f;  // 1.0 = 完全吸收

        [Header("恢复")]
        [Min(0f)]
        public float RechargeDelay          = 2.5f;  // 最后一次受击后延迟恢复的秒数
        [Min(0f)]
        public float RechargeRate           = 20f;   // 每秒恢复耐久点数
        public bool  FullRechargeOnSavePoint = true; // 激活存档点时护盾立即满值

        [Header("破碎惩罚")]
        [Min(0f)]
        public float BrokenPenaltyDuration  = 3.0f;  // 护盾破碎后无法恢复的时间

        [Header("弹反加成P1")]
        [Range(0f, 1f)]
        public float ParryRestoreRatio      = 0.3f;  // 成功格挡时恢复护盾耐久比例
    }
}

资产路径Assets/ScriptableObjects/Player/Shield_Config.asset


4. ShieldComponent

namespace BaseGames.Combat
{
    /// <summary>
    /// 挂在 PlayerController 子节点 [Shield] 上。
    /// 在 HurtBox 和 IDamageablePlayerStats之间担当拦截层。
    /// </summary>
    public class ShieldComponent : MonoBehaviour, IShieldable
    {
        // ── Inspector ───────────────────────────────────────
        [SerializeField] ShieldConfigSO                _config;
        [SerializeField] IntEventChannelSO             _onShieldHPChanged;       // 广播当前耐久整数
        [SerializeField] VoidEventChannelSO            _onShieldBrokenChannel;
        [SerializeField] VoidEventChannelSO            _onShieldRestoredChannel;
        [SerializeField] DifficultyChangedEventChannel _onDifficultyChanged;

        // ── Runtime State ────────────────────────────────────
        int   _currentShieldHP;
        bool  _isBroken;
        float _timeSinceLastHit;
        float _brokenTimer;

        // ── IShieldable ─────────────────────────────────────
        public bool HasShield       => _currentShieldHP > 0 && !_isBroken;
        public int  CurrentShieldHP => _currentShieldHP;
        public int  MaxShieldHP     => _config.MaxShieldHP;

        void Awake() => _currentShieldHP = _config.MaxShieldHP;

        void OnEnable()
        {
            _onDifficultyChanged.OnEventRaised += OnDifficultyChanged;
        }

        void OnDisable()
        {
            _onDifficultyChanged.OnEventRaised -= OnDifficultyChanged;
        }

        void Update()
        {
            if (_isBroken)
            {
                _brokenTimer += Time.deltaTime;
                if (_brokenTimer >= _config.BrokenPenaltyDuration)
                {
                    _isBroken    = false;
                    _brokenTimer = 0f;
                }
                return;
            }

            if (_currentShieldHP < _config.MaxShieldHP)
            {
                _timeSinceLastHit += Time.deltaTime;
                if (_timeSinceLastHit >= _config.RechargeDelay)
                {
                    _currentShieldHP = Mathf.Min(
                        _config.MaxShieldHP,
                        _currentShieldHP + Mathf.RoundToInt(_config.RechargeRate * Time.deltaTime)
                    );
                }
            }
        }

        /// <summary>
        /// 护盾拦截伤害。由 HurtBox.ReceiveDamage 调用。
        /// 返回剩余穿透伤害値0 = 完全吸收)。
        /// 通过 EVT_ShieldHPChanged 更新 ShieldBarUI不直接修改 DamageInfo。
        /// </summary>
        public int AbsorbDamage(int incomingDamage)
        {
            _timeSinceLastHit = 0f;

            int absorbed = Mathf.RoundToInt(incomingDamage * _config.DamageAbsorptionRatio);
            int passThrough = incomingDamage - absorbed;

            _currentShieldHP -= absorbed;

            if (_currentShieldHP <= 0)
            {
                // 护盾破碎:多余伤害穿透
                passThrough    += Mathf.Abs(_currentShieldHP);
                _currentShieldHP = 0;
                _isBroken        = true;
                _brokenTimer     = 0f;
                _onShieldBrokenChannel.Raise();
            }

            _onShieldHPChanged.Raise(_currentShieldHP);  // 更新 ShieldBarUI

            return passThrough;
        }

        /// <summary>存档点激活时调用(若配置允许)。</summary>
        public void FullRecharge()
        {
            if (!_config.FullRechargeOnSavePoint) return;
            _currentShieldHP = _config.MaxShieldHP;
            _isBroken        = false;
            _brokenTimer     = 0f;
            _onShieldRestoredChannel.Raise();
        }

        /// <summary>存档加载时恢复护盾状态。由 PlayerController.LoadFromSaveData() 调用。</summary>
        public void SetShieldHP(int hp, bool isBroken)
        {
            _currentShieldHP = Mathf.Clamp(hp, 0, _config.MaxShieldHP);
            _isBroken        = isBroken;
            _brokenTimer     = 0f;
            _timeSinceLastHit = 0f;
        }

        /// <summary>弹反成功时恢复护盾P1。</summary>
        public void OnParrySuccess()
        {
            int restore = Mathf.RoundToInt(_config.MaxShieldHP * _config.ParryRestoreRatio);
            _currentShieldHP = Mathf.Min(_config.MaxShieldHP, _currentShieldHP + restore);
        }

        void OnDifficultyChanged(DifficultyScalerSO scaler)
        {
            // 难度变更时按比例调整护盾(可选,若 ShieldConfigSO 有难度钩子字段则使用)
        }
    }
}

5. IShieldable 接口

namespace BaseGames.Combat
{
    /// <summary>
    /// 可拥有护盾的实体接口。HurtBox 持有此接口引用,在受击时优先检查护盾。
    /// </summary>
    public interface IShieldable
    {
        bool HasShield       { get; }
        int  CurrentShieldHP { get; }
        int  MaxShieldHP     { get; }
        /// <summary>拦截 incomingDamage返回剩余穿透伤害0 = 完全吸收)。</summary>
        int  AbsorbDamage(int incomingDamage);
        void FullRecharge();
        void OnParrySuccess();
    }
}

6. 护盾恢复机制

时机 行为
最后一次受击后 RechargeDelay 开始按 RechargeRate/s 线性恢复
护盾破碎后 BrokenPenaltyDuration 破碎状态结束,恢复计时重新开始
激活存档点 SavePoint.Activate()ShieldComponent.FullRecharge()(若配置为 true
弹反成功 ParrySystem.OnParrySuccessShieldComponent.OnParrySuccess()

7. 护盾 UI 集成

护盾 UI 显示在 HUD 的 HP 条上方(或并列):

// HUDController 中订阅护盾变化
// ShieldBarUI 组件,与 HP Bar 类似设计
public class ShieldBarUI : MonoBehaviour
{
    [SerializeField] Image                _fill;
    [SerializeField] ShieldComponent      _shield;
    [SerializeField] GameObject           _brokenIndicator;    // 护盾破碎时显示红色图标

    [Header("Event Channels")]
    [SerializeField] IntEventChannelSO    _onShieldHPChanged;  // 订阅耐久变化
    [SerializeField] VoidEventChannelSO   _onShieldBrokenChannel;
    [SerializeField] VoidEventChannelSO   _onShieldRestoredChannel;

    void OnEnable()
    {
        _onShieldHPChanged.OnEventRaised   += RefreshFill;
        _onShieldBrokenChannel.OnEventRaised    += ShowBroken;
        _onShieldRestoredChannel.OnEventRaised  += HideBroken;
    }

    void OnDisable()
    {
        _onShieldHPChanged.OnEventRaised   -= RefreshFill;
        _onShieldBrokenChannel.OnEventRaised    -= ShowBroken;
        _onShieldRestoredChannel.OnEventRaised  -= HideBroken;
    }

    private void RefreshFill(int currentHP)
    {
        _fill.fillAmount = _shield.MaxShieldHP > 0
            ? (float)currentHP / _shield.MaxShieldHP : 0f;
    }

    private void ShowBroken() => _brokenIndicator.SetActive(true);
    private void HideBroken() => _brokenIndicator.SetActive(false);
}

8. 弹反集成P1

弹反成功时,ParrySystem.HandleSuccessfulParry() 末尾调用(见 06_CombatModule §8

if (_controller.TryGetComponent<ShieldComponent>(out var shield))
    shield.OnParrySuccess();

此处 HandleSuccessfulParry()ParrySystem 中处理弹反成功后统一逻辑的方法,同时负责广播 _onParrySuccessParryInfoEventChannelSO)事件、奖励灵力、触发子弹时间。ShieldComponent.OnParrySuccess() 通过直接调用(而非事件订阅)接收通知,以保证执行顺序。


9. SaveData 集成

PlayerSaveData 中新增字段:

public int  ShieldHP;           // 当前护盾耐久(-1 = 使用最大值,即满护盾)
public bool ShieldIsBroken;     // 是否处于破碎状态

加载时PlayerController.LoadFromSaveData

if (saveData.ShieldHP >= 0)
    _shield.SetShieldHP(saveData.ShieldHP, saveData.ShieldIsBroken);
else
    _shield.FullRecharge();

10. 事件频道

频道 SO Payload 发布者 订阅者
EVT_ShieldHPChanged int(当前耐久值) ShieldComponent ShieldBarUI(更新护盾条填充)
EVT_ShieldBroken void ShieldComponent PlayerFeedback(破碎音效/特效)、HUDController(护盾破碎 UI
EVT_ShieldRestored void ShieldComponent HUDController(护盾恢复 UI

Player Prefab 层级更新

[Player]
├── PlayerController.cs
│   └── [SerializeField] ShieldComponent _shield ← 新增
│
├── [Combat]
│   ├── HurtBox.cs
│   │   └── [SerializeField] IShieldable _shieldable ← 由 PlayerController.Awake() 注入
│   └── HitBox.cs
│
└── [Shield]                                          ← 新增子节点
    └── ShieldComponent.cs