473 lines
15 KiB
Markdown
473 lines
15 KiB
Markdown
# 30 · 护盾力学系统
|
||
|
||
> **命名空间** `BaseGames.Player.Shield`
|
||
> **所属文档集** [← 返回索引](./README.md) · [总览](./00_Overview.md)
|
||
> **依赖** `BaseGames.Core.Events` · `BaseGames.Player`(PlayerStats · PlayerCombat)· `BaseGames.UI`
|
||
|
||
---
|
||
|
||
## 目录
|
||
|
||
1. [系统总览](#1-系统总览)
|
||
2. [ShieldConfigSO — 护盾配置](#2-shieldconfigso--护盾配置)
|
||
3. [ShieldComponent — 护盾运行时](#3-shieldcomponent--护盾运行时)
|
||
4. [伤害拦截流程](#4-伤害拦截流程)
|
||
5. [护盾恢复机制](#5-护盾恢复机制)
|
||
6. [护盾破碎反馈](#6-护盾破碎反馈)
|
||
7. [格挡系统集成(Parry,P1)](#7-格挡系统集成parryp1)
|
||
8. [护盾 UI 指示器](#8-护盾-ui-指示器)
|
||
9. [SaveData 集成](#9-savedata-集成)
|
||
10. [编辑器友好设计](#10-编辑器友好设计)
|
||
|
||
---
|
||
|
||
## 1. 系统总览
|
||
|
||
护盾是独立于玩家 HP 之外的**第二道防御层**:护盾耐久 > 0 时,受到的伤害优先由护盾吸收(可配置吸收比例);护盾耐久归零触发**护盾破碎**(短暂无法重新充能);一段时间无受击后护盾开始**自动回充**。
|
||
|
||
```
|
||
伤害输入
|
||
│
|
||
▼
|
||
ShieldComponent.AbsorbDamage()
|
||
├─ 护盾耐久 > 0 ──→ 吸收(全量或比例)+ 扣护盾耐久
|
||
│ └─ 耐久归零 → 触发护盾破碎
|
||
└─ 护盾已破碎 ──→ 直接穿透到 PlayerStats.TakeDamage()
|
||
```
|
||
|
||
**设计约束**:
|
||
- `ShieldComponent` 不直接调用 `PlayerStats`,而是通过 `DamageAbsorbedEvent` / `DamagePassedThroughEvent` 事件频道传递结果,由 `PlayerStats` 监听处理。
|
||
- 护盾耐久、护盾破碎状态持久化至 `SaveData`(使护盾在存档点完全恢复成为可配置选项)。
|
||
|
||
---
|
||
|
||
## 2. ShieldConfigSO — 护盾配置
|
||
|
||
```csharp
|
||
[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("格挡恢复(Parry,P1 功能)")]
|
||
[Range(0f, 1f)]
|
||
public float ParryRestoreRatio = 0.3f; // 成功格挡时恢复护盾耐久比例
|
||
}
|
||
```
|
||
|
||
**资产存放路径**:`Assets/ScriptableObjects/Player/Shield_Config.asset`
|
||
|
||
---
|
||
|
||
## 3. ShieldComponent — 护盾运行时
|
||
|
||
```csharp
|
||
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` 是第一道过滤层:
|
||
|
||
```csharp
|
||
// 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`,配合较低的 `MaxShieldHP`(60 点,约等于 3 发普通攻击)。
|
||
|
||
---
|
||
|
||
## 5. 护盾恢复机制
|
||
|
||
```
|
||
最后一次受击
|
||
│
|
||
▼
|
||
_timeSinceLastHit 开始计时
|
||
│
|
||
▼
|
||
达到 RechargeDelay(2.5s)后
|
||
│
|
||
▼
|
||
每帧回充 RechargeRate × deltaTime(20 点/秒)
|
||
│
|
||
▼
|
||
恢复至 MaxShieldHP → 触发 OnShieldRestored(UI 播放满值动画)
|
||
```
|
||
|
||
**破碎惩罚**:护盾破碎后 `BrokenPenaltyDuration`(3.0s)内不进入回充计时,期间 UI 显示"破碎"状态(红色闪烁)。
|
||
|
||
---
|
||
|
||
## 6. 护盾破碎反馈
|
||
|
||
通过 **Feel** 的 MMFeedbacks 播放破碎效果:
|
||
|
||
```csharp
|
||
// 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.25s(MMCameraShake)|
|
||
| 粒子爆裂 | ShieldBreakVFX(蓝色碎片,持续 0.4s)|
|
||
| 玩家闪白 | 持续 0.1s(SpriteRenderer 颜色 tint)|
|
||
|
||
---
|
||
|
||
## 7. 格挡系统集成(Parry,P1)
|
||
|
||
格挡(Parry)是 P1 规划功能,与护盾的交互设计如下:
|
||
|
||
```
|
||
玩家在攻击前摇帧内按下 [Guard] 键
|
||
│
|
||
▼
|
||
ParrySystem.TryParry(HitInfo info)
|
||
├─ 成功格挡 → 反弹/无伤 + ShieldComponent.OnParrySuccess()
|
||
│ (恢复 30% 护盾耐久,视觉反馈)
|
||
└─ 格挡失败 → 护盾吸收正常伤害
|
||
```
|
||
|
||
**格挡条件**(P1 具体值 TBD):
|
||
|
||
- 格挡窗口:攻击动画前摇的最后 3 帧(约 50ms @ 60fps)
|
||
- 成功格挡动画:`Anim_Parry_Success`(Animancer 状态切换)
|
||
- 成功格挡音效:`SFX_Parry`(高频金属碰撞音)
|
||
|
||
---
|
||
|
||
## 8. 护盾 UI 指示器
|
||
|
||
护盾耐久以**独立小条**显示于 HP 条正下方:
|
||
|
||
```
|
||
┌───────────────────────────────────┐
|
||
│ ♥ ♥ ♥ ♥ ♥ HP Bar │
|
||
│ ▪▪▪▪▪▪▪▪▪ Shield Bar(蓝色) │
|
||
└───────────────────────────────────┘
|
||
```
|
||
|
||
### ShieldBarUI.cs
|
||
|
||
```csharp
|
||
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 样式(护盾条)
|
||
|
||
```css
|
||
/* 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 集成
|
||
|
||
```json
|
||
"shield": {
|
||
"currentHP": 60,
|
||
"isBroken": false
|
||
}
|
||
```
|
||
|
||
```csharp
|
||
// 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
|
||
|
||
```csharp
|
||
[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
|
||
|
||
```csharp
|
||
#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
|
||
```
|