15 KiB
15 KiB
30 · 护盾力学系统
命名空间
BaseGames.Player.Shield
所属文档集 ← 返回索引 · 总览
依赖BaseGames.Core.Events·BaseGames.Player(PlayerStats · PlayerCombat)·BaseGames.UI
目录
- 系统总览
- ShieldConfigSO — 护盾配置
- ShieldComponent — 护盾运行时
- 伤害拦截流程
- 护盾恢复机制
- 护盾破碎反馈
- 格挡系统集成(Parry,P1)
- 护盾 UI 指示器
- SaveData 集成
- 编辑器友好设计
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("格挡恢复(Parry,P1 功能)")]
[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,配合较低的MaxShieldHP(60 点,约等于 3 发普通攻击)。
5. 护盾恢复机制
最后一次受击
│
▼
_timeSinceLastHit 开始计时
│
▼
达到 RechargeDelay(2.5s)后
│
▼
每帧回充 RechargeRate × deltaTime(20 点/秒)
│
▼
恢复至 MaxShieldHP → 触发 OnShieldRestored(UI 播放满值动画)
破碎惩罚:护盾破碎后 BrokenPenaltyDuration(3.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.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
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