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

15 KiB
Raw Permalink Blame History

30 · 护盾力学系统

命名空间 BaseGames.Player.Shield
所属文档集 ← 返回索引 · 总览
依赖 BaseGames.Core.Events · BaseGames.PlayerPlayerStats · PlayerCombat· BaseGames.UI


目录

  1. 系统总览
  2. ShieldConfigSO — 护盾配置
  3. ShieldComponent — 护盾运行时
  4. 伤害拦截流程
  5. 护盾恢复机制
  6. 护盾破碎反馈
  7. 格挡系统集成ParryP1
  8. 护盾 UI 指示器
  9. SaveData 集成
  10. 编辑器友好设计

1. 系统总览

护盾是独立于玩家 HP 之外的第二道防御层:护盾耐久 > 0 时,受到的伤害优先由护盾吸收(可配置吸收比例);护盾耐久归零触发护盾破碎(短暂无法重新充能);一段时间无受击后护盾开始自动回充

伤害输入
  │
  ▼
ShieldComponent.AbsorbDamage()
  ├─ 护盾耐久 > 0 ──→ 吸收(全量或比例)+ 扣护盾耐久
  │                    └─ 耐久归零 → 触发护盾破碎
  └─ 护盾已破碎  ──→ 直接穿透到 PlayerStats.TakeDamage()

设计约束

  • ShieldComponent 不直接调用 PlayerStats,而是通过 DamageAbsorbedEvent / DamagePassedThroughEvent 事件频道传递结果,由 PlayerStats 监听处理。
  • 护盾耐久、护盾破碎状态持久化至 SaveData(使护盾在存档点完全恢复成为可配置选项)。

2. ShieldConfigSO — 护盾配置

[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 = 完全吸收0.5 = 吸收一半

    [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("格挡恢复ParryP1 功能)")]
    [Range(0f, 1f)]
    public float           ParryRestoreRatio      = 0.3f; // 成功格挡时恢复护盾耐久比例
}

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


3. ShieldComponent — 护盾运行时

namespace BaseGames.Player.Shield
{
    [DefaultExecutionOrder(-40)]
    public class ShieldComponent : MonoBehaviour
    {
        // ── 配置 ─────────────────────────────────────────────────
        [SerializeField] ShieldConfigSO           _config;
        [SerializeField] VoidEventChannelSO        _onSavePointActivated;
        [SerializeField] IntEventChannelSO         _onShieldHPChanged;    // 发布:当前耐久
        [SerializeField] VoidEventChannelSO        _onShieldBroken;       // 发布
        [SerializeField] VoidEventChannelSO        _onShieldRestored;     // 发布(完全恢复)
        [SerializeField] IntEventChannelSO         _onDamagePassedThrough; // 发布:穿透伤害量

        // ── 状态 ─────────────────────────────────────────────────
        public int   CurrentShieldHP  { get; private set; }
        public int   MaxShieldHP      => _config.MaxShieldHP;
        public bool  IsShieldBroken   { get; private set; }
        public float ShieldRatio      => (float)CurrentShieldHP / MaxShieldHP; // 0~1

        float _timeSinceLastHit;
        float _brokenTimer;

        void OnEnable()
        {
            CurrentShieldHP = _config.MaxShieldHP;
            _onSavePointActivated.OnEventRaised += OnSavePoint;
        }

        void OnDisable()
        {
            _onSavePointActivated.OnEventRaised -= OnSavePoint;
        }

        void Update()
        {
            if (IsShieldBroken)
            {
                _brokenTimer += Time.deltaTime;
                if (_brokenTimer >= _config.BrokenPenaltyDuration)
                    RecoverFromBroken();
                return;
            }
            // 延迟回充
            _timeSinceLastHit += Time.deltaTime;
            if (_timeSinceLastHit >= _config.RechargeDelay && CurrentShieldHP < MaxShieldHP)
            {
                int restored = Mathf.CeilToInt(_config.RechargeRate * Time.deltaTime);
                int prev = CurrentShieldHP;
                CurrentShieldHP = Mathf.Min(CurrentShieldHP + restored, MaxShieldHP);
                if (prev != CurrentShieldHP)
                {
                    _onShieldHPChanged.Raise(CurrentShieldHP);
                    if (CurrentShieldHP == MaxShieldHP)
                        _onShieldRestored.Raise();
                }
            }
        }

        /// <summary>
        /// 外部PlayerCombat调用处理一次伤害输入。
        /// 返回实际穿透到 PlayerStats 的伤害量(护盾吸收后的余量)。
        /// </summary>
        public int AbsorbDamage(int incomingDamage)
        {
            if (IsShieldBroken)
            {
                _onDamagePassedThrough.Raise(incomingDamage);
                return incomingDamage;
            }
            int absorb     = Mathf.CeilToInt(incomingDamage * _config.DamageAbsorptionRatio);
            absorb         = Mathf.Min(absorb, CurrentShieldHP);   // 不能超过当前耐久
            int passThrough = incomingDamage - absorb;

            CurrentShieldHP     -= absorb;
            _timeSinceLastHit    = 0f;
            _onShieldHPChanged.Raise(CurrentShieldHP);

            if (CurrentShieldHP <= 0)
                TriggerShieldBreak();

            if (passThrough > 0)
                _onDamagePassedThrough.Raise(passThrough);

            return passThrough;
        }

        /// <summary>格挡成功时恢复部分护盾Parry 系统调用)</summary>
        public void OnParrySuccess()
        {
            if (IsShieldBroken) return;
            int restore     = Mathf.RoundToInt(MaxShieldHP * _config.ParryRestoreRatio);
            int prev        = CurrentShieldHP;
            CurrentShieldHP = Mathf.Min(CurrentShieldHP + restore, MaxShieldHP);
            if (prev != CurrentShieldHP)
                _onShieldHPChanged.Raise(CurrentShieldHP);
        }

        void TriggerShieldBreak()
        {
            CurrentShieldHP = 0;
            IsShieldBroken  = true;
            _brokenTimer    = 0f;
            _onShieldBroken.Raise();
        }

        void RecoverFromBroken()
        {
            IsShieldBroken  = false;
            _timeSinceLastHit = _config.RechargeDelay; // 立即开始回充
        }

        void OnSavePoint()
        {
            if (!_config.FullRechargeOnSavePoint) return;
            CurrentShieldHP = MaxShieldHP;
            IsShieldBroken  = false;
            _brokenTimer    = 0f;
            _timeSinceLastHit = 0f;
            _onShieldHPChanged.Raise(CurrentShieldHP);
        }
    }
}

4. 伤害拦截流程

PlayerCombat.TakeHit() 中,ShieldComponent 是第一道过滤层:

// PlayerCombat.TakeHit(HitInfo info)
int remainingDamage = _shield != null
    ? _shield.AbsorbDamage(info.Damage)
    : info.Damage;

if (remainingDamage > 0)
    _playerStats.TakeDamage(remainingDamage);

吸收比例说明

DamageAbsorptionRatio 行为
1.0(默认) 护盾耐久充足时完全吸收,无溢出伤害到 HP
0.5 护盾承担 50% 伤害,剩余 50% 直接扣 HP
0.0 护盾仅作为计数器,所有伤害直接到 HP

建议起始配置:DamageAbsorptionRatio = 1.0,配合较低的 MaxShieldHP60 点,约等于 3 发普通攻击)。


5. 护盾恢复机制

最后一次受击
  │
  ▼
_timeSinceLastHit 开始计时
  │
  ▼
达到 RechargeDelay2.5s)后
  │
  ▼
每帧回充 RechargeRate × deltaTime20 点/秒)
  │
  ▼
恢复至 MaxShieldHP → 触发 OnShieldRestoredUI 播放满值动画)

破碎惩罚:护盾破碎后 BrokenPenaltyDuration3.0s)内不进入回充计时,期间 UI 显示"破碎"状态(红色闪烁)。


6. 护盾破碎反馈

通过 Feel 的 MMFeedbacks 播放破碎效果:

// ShieldFeedbackReceiver : MonoBehaviour挂载在玩家对象
[SerializeField] VoidEventChannelSO _onShieldBroken;
[SerializeField] MMFeedbacks        _shieldBreakFeedback;   // 音效 + 屏幕震动 + 粒子
[SerializeField] MMFeedbacks        _shieldRestoredFeedback;// 恢复满值时的轻微提示音

void OnEnable()
{
    _onShieldBroken.OnEventRaised   += () => _shieldBreakFeedback.PlayFeedbacks();
    _onShieldRestored.OnEventRaised += () => _shieldRestoredFeedback.PlayFeedbacks();
}

破碎效果清单

效果 参数
音效(破碎) SFX_Shield_Break音量 0.9,间距 ±0.05 半音
屏幕震动 振幅 0.15,持续 0.25sMMCameraShake
粒子爆裂 ShieldBreakVFX蓝色碎片持续 0.4s
玩家闪白 持续 0.1sSpriteRenderer 颜色 tint

7. 格挡系统集成ParryP1

格挡Parry是 P1 规划功能,与护盾的交互设计如下:

玩家在攻击前摇帧内按下 [Guard] 键
  │
  ▼
ParrySystem.TryParry(HitInfo info)
  ├─ 成功格挡 → 反弹/无伤 + ShieldComponent.OnParrySuccess()
  │               (恢复 30% 护盾耐久,视觉反馈)
  └─ 格挡失败 → 护盾吸收正常伤害

格挡条件P1 具体值 TBD

  • 格挡窗口:攻击动画前摇的最后 3 帧(约 50ms @ 60fps
  • 成功格挡动画:Anim_Parry_SuccessAnimancer 状态切换)
  • 成功格挡音效:SFX_Parry(高频金属碰撞音)

8. 护盾 UI 指示器

护盾耐久以独立小条显示于 HP 条正下方:

┌───────────────────────────────────┐
│  ♥ ♥ ♥ ♥ ♥  HP Bar               │
│  ▪▪▪▪▪▪▪▪▪  Shield Bar蓝色    │
└───────────────────────────────────┘

ShieldBarUI.cs

namespace BaseGames.UI
{
    public class ShieldBarUI : MonoBehaviour
    {
        [SerializeField] IntEventChannelSO  _onShieldHPChanged;
        [SerializeField] VoidEventChannelSO _onShieldBroken;
        [SerializeField] VoidEventChannelSO _onShieldRestored;

        UIDocument    _doc;
        VisualElement _fill;
        Label         _valueLabel;
        int           _maxHP;

        void OnEnable()
        {
            _doc        = GetComponent<UIDocument>();
            _fill       = _doc.rootVisualElement.Q("ShieldFill");
            _valueLabel = _doc.rootVisualElement.Q<Label>("ShieldValue");
            _maxHP      = FindFirstObjectByType<ShieldComponent>().MaxShieldHP;

            _onShieldHPChanged.OnEventRaised += OnHPChanged;
            _onShieldBroken.OnEventRaised    += OnBroken;
            _onShieldRestored.OnEventRaised  += OnRestored;
        }

        void OnDisable()
        {
            _onShieldHPChanged.OnEventRaised -= OnHPChanged;
            _onShieldBroken.OnEventRaised    -= OnBroken;
            _onShieldRestored.OnEventRaised  -= OnRestored;
        }

        void OnHPChanged(int current)
        {
            float ratio = (float)current / _maxHP;
            _fill.style.width = Length.Percent(ratio * 100f);
            _valueLabel.text  = current.ToString();
            _fill.RemoveFromClassList("shield-broken");
        }

        void OnBroken()
        {
            _fill.AddToClassList("shield-broken"); // USS: 红色闪烁动画
        }

        void OnRestored()
        {
            _fill.AddToClassList("shield-full-flash"); // USS: 短暂白色高亮
            // 500ms 后移除
            _fill.schedule.Execute(() =>
                _fill.RemoveFromClassList("shield-full-flash")).StartingIn(500);
        }
    }
}

USS 样式(护盾条)

/* Assets/UI/Styles/ShieldBar.uss */
#ShieldFill {
    background-color: rgb(80, 160, 230);
    height: 6px;
    border-radius: 3px;
    transition-property: width;
    transition-duration: 0.1s;
}

.shield-broken {
    background-color: rgb(200, 60, 60);
    animation-name: shield-blink;
    animation-duration: 0.4s;
    animation-iteration-count: 3;
}

.shield-full-flash {
    background-color: white;
}

@keyframes shield-blink {
    0%   { opacity: 1; }
    50%  { opacity: 0.2; }
    100% { opacity: 1; }
}

9. SaveData 集成

"shield": {
  "currentHP": 60,
  "isBroken": false
}
// ShieldComponent
public ShieldSaveData GetSaveData() => new ShieldSaveData
{
    CurrentHP = CurrentShieldHP,
    IsBroken  = IsShieldBroken,
};

public void LoadSaveData(ShieldSaveData data)
{
    CurrentShieldHP = data.CurrentHP;
    IsShieldBroken  = data.IsBroken;
    _brokenTimer    = 0f;
    _onShieldHPChanged.Raise(CurrentShieldHP);
}

通常在激活存档点时护盾全满(FullRechargeOnSavePoint = true),因此存档数据中的护盾值主要用于中途存档(如检查点)。


10. 编辑器友好设计

ShieldConfigSO 自定义 Inspector

[CustomEditor(typeof(ShieldConfigSO))]
public class ShieldConfigSOEditor : Editor
{
    public override VisualElement CreateInspectorGUI()
    {
        var root   = new VisualElement();
        InspectorElement.FillDefaultInspector(root, serializedObject, this);

        var cfg    = (ShieldConfigSO)target;
        var stats  = new Foldout { text = "护盾估算" };
        float fillTime = cfg.MaxShieldHP / Mathf.Max(cfg.RechargeRate, 0.01f);
        stats.Add(new Label($"满值充能时间(无受击): {fillTime:F1} 秒"));
        stats.Add(new Label($"破碎冷却后开始恢复: {cfg.BrokenPenaltyDuration:F1} + {cfg.RechargeDelay:F1} = {cfg.BrokenPenaltyDuration + cfg.RechargeDelay:F1} 秒"));
        stats.Add(new Label($"吸收比例: {cfg.DamageAbsorptionRatio * 100:F0}%"));
        root.Add(stats);
        return root;
    }
}

场景 Gizmos

#if UNITY_EDITOR
void OnDrawGizmosSelected()
{
    // 护盾状态可视化
    var color = IsShieldBroken ? Color.red : new Color(0.3f, 0.7f, 1f, 0.5f);
    Gizmos.color = color;
    float r = 0.7f * ShieldRatio;
    Gizmos.DrawWireSphere(transform.position, r);
    UnityEditor.Handles.Label(transform.position + Vector3.up * 1.0f,
        $"Shield {CurrentShieldHP}/{MaxShieldHP}");
}
#endif