多轮审查和修复

This commit is contained in:
2026-05-12 15:34:08 +08:00
parent f55d2a57c3
commit ebbbb7332e
805 changed files with 838724 additions and 1905 deletions

View File

@@ -0,0 +1,27 @@
using UnityEngine;
namespace BaseGames.Combat
{
/// <summary>
/// 抛物线抛射物。以 <see cref="ProjectileConfigSO.LaunchAngleDeg"/> 指定抛射角,
/// 并启用重力缩放,使子弹呈弧线飞行。
/// </summary>
public class ArcProjectile : Projectile
{
protected override void OnInitialized()
{
float rad = _config.LaunchAngleDeg * Mathf.Deg2Rad;
float vX = Mathf.Cos(rad) * _config.Speed * Mathf.Sign(Direction.x == 0f ? 1f : Direction.x);
float vY = Mathf.Sin(rad) * _config.Speed;
_rb.velocity = new Vector2(vX, vY);
_rb.gravityScale = _config.GravityScale;
}
protected override void OnDisable()
{
base.OnDisable();
_rb.gravityScale = 0f;
}
}
}

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: b9c4356cd693b604bb0889f9538eb13e
guid: f43e5039135c2f84682862a9249e2688
MonoImporter:
externalObjects: {}
serializedVersion: 2

View File

@@ -8,6 +8,7 @@
"versionDefines": [],
"rootNamespace": "BaseGames.Combat",
"references": [
"BaseGames.Core",
"BaseGames.Core.Events",
"BaseGames.Parry"
],

View File

@@ -0,0 +1,24 @@
using UnityEngine;
namespace BaseGames.Combat
{
/// <summary>
/// 拼刀系统参数配置(架构 06_CombatModule §15
/// 资产路径: Assets/ScriptableObjects/Config/Combat/ClashConfig.asset
/// </summary>
[CreateAssetMenu(menuName = "Combat/ClashConfig")]
public class ClashConfigSO : ScriptableObject
{
[Header("HitStop")]
[Tooltip("拼刀冻帧数(比普通命中的 2 帧更短)")]
public int ClashFreezeFrames = 1;
[Header("弹开")]
[Tooltip("拼刀弹开力度Impulse 模式)")]
public float ClashKnockbackForce = 6.0f;
[Header("Camera Impulse")]
[Tooltip("Cinemachine Impulse 强度(轻微震感)")]
public float ClashImpulseStrength = 0.3f;
}
}

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 1dfc988231a6ac14a9aa035ba1719ab0
guid: d2dce4002cf4f99429388b2038fa2f60
MonoImporter:
externalObjects: {}
serializedVersion: 2

View File

@@ -0,0 +1,70 @@
using System.Collections.Generic;
using UnityEngine;
using BaseGames.Core;
using BaseGames.Core.Events;
namespace BaseGames.Combat
{
/// <summary>
/// 拼刀系统单例服务(架构 06_CombatModule §15
/// 常驻 Persistent 场景,由 GameManager 持有。
/// 当玩家与敌人的近战 HitBox 同时激活并相互重叠时触发拼刀:双方均不扣血,各自弹开。
/// </summary>
[DefaultExecutionOrder(-500)]
public class ClashResolver : MonoBehaviour, IClashService
{
[SerializeField] private VoidEventChannelSO _onNailClash; // EVT_NailClash
[SerializeField] private ClashConfigSO _config;
// 防止同一碰撞在同帧被双方 HitBox 各触发一次(去重)
// Key = (min(idA,idB), max(idA,idB)),顺序无关且无 XOR 哈希碰撞风险
private readonly HashSet<(int, int)> _processedThisFrame = new();
private void Awake()
{
if (ServiceLocator.GetOrDefault<IClashService>() != null)
{
Destroy(gameObject);
return;
}
ServiceLocator.Register<IClashService>(this);
Debug.Assert(_config != null, "[ClashResolver] _config 未赋值,请在 Inspector 中指定 ClashConfigSO。", this);
}
private void LateUpdate() => _processedThisFrame.Clear();
/// <summary>
/// 由 HitBox.OnTriggerEnter2D 调用。
/// 对同一对 HitBox 每帧只处理一次HashSet 去重)。
/// </summary>
public void ResolveClash(HitBox hitBoxA, HitBox hitBoxB)
{
int idA = hitBoxA.GetInstanceID();
int idB = hitBoxB.GetInstanceID();
(int, int) key = (System.Math.Min(idA, idB), System.Math.Max(idA, idB));
if (!_processedThisFrame.Add(key)) return;
// 1. 拼刀冻帧(比普通命中的 2 帧更短,强化拼刀手感)
ServiceLocator.GetOrDefault<IHitStopService>()?.FreezeFrames(_config.ClashFreezeFrames);
// 2. 双方弹开
ApplyClashKnockback(hitBoxA.OwnerRigidbody, hitBoxB.transform.position);
ApplyClashKnockback(hitBoxB.OwnerRigidbody, hitBoxA.transform.position);
// 3. 广播事件VFX / Audio / CameraImpulse 订阅)
_onNailClash?.Raise();
}
private void ApplyClashKnockback(Rigidbody2D rb, Vector2 oppositePos)
{
if (rb == null) return;
Vector2 dir = ((Vector2)rb.transform.position - oppositePos).normalized;
rb.AddForce(dir * _config.ClashKnockbackForce, ForceMode2D.Impulse);
}
private void OnDestroy()
{
ServiceLocator.Unregister<IClashService>(this);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: df7a93b79e446e24dbb27d2971c85f39
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -5,12 +5,13 @@ namespace BaseGames.Combat
{
/// <summary>
/// 单次伤害信息。流水线RawDamage → Amount护盾修改→ FinalDamage防御减免后
/// ⚠️ 非 readonly struct — Builder 就地写入字段
/// ⚠️ 保留为可变 structHurtBox 流水线需要在方法内修改本地副本的 Amount / FinalDamage
/// Builder 通过独立字段构造,不直接修改 DamageInfo 实例。
/// </summary>
[System.Serializable]
public struct DamageInfo
{
public int RawDamage; // HitBox 设定的原始值Builder.SetRaw 写入一次)
public int RawDamage; // HitBox 设定的原始值(工厂/Builder 写入一次)
public int Amount; // 流水线中被护盾/防御修改
public int FinalDamage; // HurtBox 写入,最终 HP 扣除量
public Vector2 KnockbackDirection;
@@ -28,50 +29,92 @@ namespace BaseGames.Combat
public string SkillId;
// ── Builder ──────────────────────────────────────────────────────────
/// <summary>
/// 通过独立字段构造 DamageInfo避免直接持有可变 DamageInfo 实例。
/// </summary>
public class Builder
{
private DamageInfo _d;
private int _raw;
private DamageType _type;
private DamageCategory _category;
private DamageFlags _flags;
private DamageTags _tags;
private string _skillId;
private string _sourceId;
private Vector2 _knockbackDirection;
private float _knockbackForce;
private float _hitStunDuration;
private HitFxType _fxType;
private BreakLevel _break;
private Vector2 _sourcePosition;
private int _sourceLayer;
public Builder() { }
// SetRaw 同步初始化 AmountAmount 始终以 RawDamage 为起点)
public Builder SetRaw(int v) { _d.RawDamage = v; _d.Amount = v; return this; }
public Builder SetType(DamageType v) { _d.Type = v; return this; }
public Builder SetCategory(DamageCategory v){ _d.Category = v; return this; }
public Builder SetFlags(DamageFlags v) { _d.Flags = v; return this; }
public Builder SetTags(DamageTags v) { _d.Tags = v; return this; }
public Builder SetSkillId(string v) { _d.SkillId = v; return this; }
public Builder SetSourceId(string v) { _d.SourceId = v; return this; }
public Builder SetKnockback(Vector2 dir, float force)
{ _d.KnockbackDirection = dir; _d.KnockbackForce = force; return this; }
public Builder SetStun(float dur) { _d.HitStunDuration = dur; return this; }
public Builder SetFx(HitFxType v) { _d.FxType = v; return this; }
public Builder SetBreak(BreakLevel v) { _d.Break = v; return this; }
public Builder SetSourcePos(Vector2 v) { _d.SourcePosition = v; return this; }
public Builder SetLayer(int v) { _d.SourceLayer = v; return this; }
public DamageInfo Build() => _d;
public Builder SetRaw(int v) { _raw = v; return this; }
public Builder SetType(DamageType v) { _type = v; return this; }
public Builder SetCategory(DamageCategory v) { _category = v; return this; }
public Builder SetFlags(DamageFlags v) { _flags = v; return this; }
public Builder SetTags(DamageTags v) { _tags = v; return this; }
public Builder SetSkillId(string v) { _skillId = v; return this; }
public Builder SetSourceId(string v) { _sourceId = v; return this; }
public Builder SetKnockback(Vector2 dir, float force) { _knockbackDirection = dir; _knockbackForce = force; return this; }
public Builder SetStun(float dur) { _hitStunDuration = dur; return this; }
public Builder SetFx(HitFxType v) { _fxType = v; return this; }
public Builder SetBreak(BreakLevel v) { _break = v; return this; }
public Builder SetSourcePos(Vector2 v) { _sourcePosition = v; return this; }
public Builder SetLayer(int v) { _sourceLayer = v; return this; }
public DamageInfo Build() => new DamageInfo
{
RawDamage = _raw,
Amount = _raw,
Type = _type,
Category = _category,
Flags = _flags,
Tags = _tags,
SkillId = _skillId,
SourceId = _sourceId,
KnockbackDirection = _knockbackDirection,
KnockbackForce = _knockbackForce,
HitStunDuration = _hitStunDuration,
FxType = _fxType,
Break = _break,
SourcePosition = _sourcePosition,
SourceLayer = _sourceLayer,
};
}
/// <summary>
/// ⚡ 零堆分配工厂(热路径首选)。直接从 DamageSourceSO 填入基础字段
/// KnockbackDirection / SourcePosition / SourceLayer 等运行时字段由调用方就地赋值。
/// ⚡ 零堆分配工厂(热路径首选)。从 DamageSourceSO 填入所有静态字段
/// 可选传入运行时字段knockbackDir、sourcePos、sourceLayer
/// 无需调用方事后就地赋值。
/// </summary>
public static DamageInfo From(DamageSourceSO so)
public static DamageInfo From(
DamageSourceSO so,
Vector2 knockbackDir = default,
Vector2 sourcePos = default,
int sourceLayer = 0)
{
int baseAmt = Mathf.RoundToInt(so.BaseDamage * so.DamageMultiplier);
return new DamageInfo
{
RawDamage = baseAmt,
Amount = baseAmt,
Type = so.Type,
Category = so.Category,
Flags = so.Flags,
Tags = so.Tags,
HitStunDuration = so.HitStunDuration,
FxType = so.FxType,
Break = so.BreakLevel,
SourceId = so.sourceId,
SkillId = so.skillId,
RawDamage = baseAmt,
Amount = baseAmt,
Type = so.Type,
Category = so.Category,
Flags = so.Flags,
Tags = so.Tags,
HitStunDuration = so.HitStunDuration,
FxType = so.FxType,
Break = so.BreakLevel,
SourceId = so.sourceId,
SkillId = so.skillId,
KnockbackDirection = knockbackDir,
KnockbackForce = so.KnockbackForce,
SourcePosition = sourcePos,
SourceLayer = sourceLayer,
};
}
}

View File

@@ -1,11 +1,12 @@
using System.Collections.Generic;
using UnityEngine;
using BaseGames.Core;
namespace BaseGames.Combat
{
/// <summary>
/// 攻击判定盒。挂载在武器 Prefab 或技能 HitBox Prefab 的子节点上。
/// Phase 1 简化:直接挂在 Player Prefab 子节点 [HitBoxGround/Up/Down/Air]。
/// 直接挂在 Player Prefab 子节点 [HitBoxGround/Up/Down/Air]。
/// Collider2D 需设 IsTrigger = trueLayer = PlayerHitBox 或 EnemyHitBox。
/// </summary>
[RequireComponent(typeof(Collider2D))]
@@ -14,12 +15,38 @@ namespace BaseGames.Combat
[SerializeField] private DamageSourceSO _defaultSource;
[SerializeField] private float _hitCooldown = 0.1f;
/// <summary>
/// HitBox 标识符,供 PlayerAnimationEvents / EnemyAnimationEvents 按名称精确激活特定判定盒。
/// 留空表示"无 Id";事件 payload 为空时将操作所有 HitBox。
/// </summary>
[SerializeField] private string _id = "";
public string Id => _id;
/// <summary>
/// 对立阵营 HitBox 所在的 Layer 掩码(用于拼刀检测)。
/// Inspector 中将 PlayerHitBox 与 EnemyHitBox 两个 Layer 均勾选。
/// </summary>
[SerializeField] private LayerMask _rivalHitBoxMask;
private DamageSourceSO _currentSource;
private Transform _attackerTransform;
private Rigidbody2D _ownerRigidbody;
private bool _isActive;
private IClashService _clashService;
/// <summary>HitBox 当前是否激活(供 ClashResolver 查询)。</summary>
public bool IsActive => _isActive;
/// <summary>当前 Source 是否携带 CanClash 标记(供 ClashResolver 查询)。</summary>
public bool CanClash => _currentSource != null && _currentSource.Flags.HasFlag(DamageFlags.CanClash);
/// <summary>宿主角色的 Rigidbody2D用于拼刀弹开力计算。</summary>
public Rigidbody2D OwnerRigidbody => _ownerRigidbody;
// 拼刀检测所需的对立层掩码Inspector 配置)
/// <summary>命中确认委托PlayerCombat / EnemyCombat 订阅)。</summary>
public System.Action<DamageInfo> OnHitConfirmed;
public event System.Action<DamageInfo> OnHitConfirmed;
/// <summary>
/// 激活 HitBox。source/attacker 均可选,未传则使用 Inspector 默认值。
@@ -30,9 +57,25 @@ namespace BaseGames.Combat
_currentSource = source ?? _defaultSource;
_attackerTransform = attacker ?? transform;
_isActive = true;
// 缓存宿主 Rigidbody2D沿父层级向上查找
_ownerRigidbody = _attackerTransform.GetComponentInParent<Rigidbody2D>();
// 每次激活清空当前激活期已命中目标集合(防止连击连段导致同一阶段多次命中目标)
_hitThisActivation.Clear();
_hitCooldownTimers.Clear();
}
public void Deactivate() => _isActive = false;
public void Deactivate()
{
_isActive = false;
_hitThisActivation.Clear();
_hitCooldownTimers.Clear();
}
/// <summary>仅替换当前 DamageSource不改变激活状态供 PlayerCombat 连击段切换)。</summary>
public void SetDamageSource(DamageSourceSO source)
{
if (source != null) _currentSource = source;
}
private void Awake()
{
@@ -40,35 +83,60 @@ namespace BaseGames.Combat
var col = GetComponent<Collider2D>();
if (!col.isTrigger)
Debug.LogWarning($"[HitBox] {name}: Collider2D.isTrigger 应为 true。", this);
// 缓存 IClashServiceOnTriggerEnter2D 为物理热路径,避免每次调用 Dictionary 查找
_clashService = ServiceLocator.GetOrDefault<IClashService>();
}
private void OnDisable()
{
_isActive = false;
_hitThisActivation.Clear();
_hitCooldownTimers.Clear();
}
private void OnTriggerEnter2D(Collider2D other)
private void OnTriggerExit2D(Collider2D other)
{
// 目标离开判定区域时清除其冷却记录,防止持续激活的 HitBox环境危险等
// 因有效目标持续流动而无限积累已离场对象。
// 注意_hitThisActivation 刻意保留,确保同一激活期内不重复命中。
_hitCooldownTimers.Remove(other);
}
private void OnTriggerEnter2D(Collider2D other) {
if (!_isActive) return;
if (_currentSource == null)
{
Debug.LogWarning($"[HitBox] {name}: 无 DamageSourceSO跳过命中。", this);
return;
}
// 同一激活期防止对同一 Collider 重复命中(一次攻击每个目标至多命中一次)
if (!_hitThisActivation.Add(other)) return;
if (!CheckCooldown(other)) return;
Vector2 knockDir = ((Vector2)other.bounds.center
- (Vector2)_attackerTransform.position).normalized;
// ⚡ 零 GCstruct 工厂,就地赋值运行时字段
var info = DamageInfo.From(_currentSource);
info.KnockbackDirection = knockDir;
info.KnockbackForce = _currentSource.KnockbackForce;
info.SourcePosition = _attackerTransform.position;
info.SourceLayer = _attackerTransform.gameObject.layer;
// ⚡ 零 GCstruct 工厂,运行时字段内联传入
var info = DamageInfo.From(
_currentSource,
knockDir,
_attackerTransform.position,
_attackerTransform.gameObject.layer);
// ① 命中 HurtBox
// ① 拼刀检测:当前 HitBox 携带 CanClash 标记,且碰到对立阵营的 HitBox
int otherLayer = other.gameObject.layer;
bool isRivalHitBoxLayer = (_rivalHitBoxMask.value & (1 << otherLayer)) != 0;
if (isRivalHitBoxLayer && CanClash)
{
var rivalHitBox = other.GetComponent<HitBox>();
if (rivalHitBox != null && rivalHitBox.IsActive && rivalHitBox.CanClash)
{
_clashService?.ResolveClash(this, rivalHitBox);
return; // 拼刀,中止伤害流水线
}
}
// ② 命中 HurtBox
var hurtBox = other.GetComponent<HurtBox>();
if (hurtBox != null)
{
@@ -77,12 +145,14 @@ namespace BaseGames.Combat
return;
}
// 命中 IBreakable机关/障碍物)
// 命中 IBreakable机关/障碍物)
other.GetComponent<IBreakable>()?.TryInteract(info);
}
// ── 同目标多帧命中冷却 ────────────────────────────────────────────────
private readonly Dictionary<Collider2D, float> _hitCooldownTimers = new();
// ── 当前激活期已命中目标集合(防止复合子 Collider 导致同帧多次命中)────────────
private readonly HashSet<Collider2D> _hitThisActivation = new(8);
// ── 同目标多帧命中冷却(防止 Trigger 处于重叠状态时重入等殊情导致的连击)──
private readonly Dictionary<Collider2D, float> _hitCooldownTimers = new(8);
private bool CheckCooldown(Collider2D other)
{
@@ -92,5 +162,20 @@ namespace BaseGames.Combat
_hitCooldownTimers[other] = now;
return true;
}
#if UNITY_EDITOR
private void OnDrawGizmos()
{
var col = GetComponent<Collider2D>();
if (col == null) return;
// 激活时显示橙色判定框,非激活时显示极淡轮廓
Gizmos.color = _isActive
? new UnityEngine.Color(1f, 0.5f, 0f, 0.55f)
: new UnityEngine.Color(1f, 0.5f, 0f, 0.1f);
Gizmos.DrawCube(col.bounds.center, col.bounds.size);
Gizmos.color = new UnityEngine.Color(1f, 0.5f, 0f, 0.9f);
Gizmos.DrawWireCube(col.bounds.center, col.bounds.size);
}
#endif
}
}

View File

@@ -0,0 +1,102 @@
using System.Collections;
using UnityEngine;
using BaseGames.Core;
namespace BaseGames.Combat
{
/// <summary>
/// 命中冻帧服务接口。
/// </summary>
public interface IHitStopService
{
/// <summary>冻帧 <paramref name="frames"/> 帧(以 fixedDeltaTime 换算为实际时长)。</summary>
void FreezeFrames(int frames);
/// <summary>冻帧指定时长Unscaled 秒)。</summary>
void FreezeDuration(float unscaledSeconds);
/// <summary>游戏正常时间缩放(默认 1子弹时间等功能修改此属性。</summary>
float BaseTimeScale { get; set; }
}
/// <summary>
/// 命中冻帧服务HitStop架构 06_CombatModule §16
/// 通过短暂将 Time.timeScale 设为 0 实现"冻帧"效果,强化打击感。
/// 常驻 Persistent 场景,由 GameManager 持有;通过 ServiceLocator 注册访问。
///
/// 设计说明:
/// - 多次并发请求取最长持续时间StopCoroutine + 重启)
/// - 使用 WaitForSecondsRealtime 确保 timeScale=0 时协程仍能恢复
/// - OnDestroy 强制还原 timeScale防止异常退出导致游戏卡死
/// </summary>
[DefaultExecutionOrder(-400)]
public class HitStopManager : MonoBehaviour, IHitStopService
{
/// <summary>游戏正常时间缩放(默认 1通过属性读取以便外部修改子弹时间时保留基准值。</summary>
public float BaseTimeScale
{
get => _baseTimeScale;
set => _baseTimeScale = Mathf.Clamp(value, 0.01f, 10f);
}
private float _baseTimeScale = 1f;
private Coroutine _activeRoutine;
private float _freezeEndTime;
// ── 生命周期 ──────────────────────────────────────────────────────
private void Awake()
{
if (ServiceLocator.GetOrDefault<IHitStopService>() != null) { Destroy(gameObject); return; }
ServiceLocator.Register<IHitStopService>(this);
}
private void OnDestroy()
{
// 安全恢复:防止场景卸载/异常退出时 timeScale 永久为 0
Time.timeScale = _baseTimeScale;
ServiceLocator.Unregister<IHitStopService>(this);
}
// ── 公共 API ──────────────────────────────────────────────────────
/// <summary>
/// 冻帧 <paramref name="frames"/> 帧(以 fixedDeltaTime 换算为实际时长)。
/// 若已有冻帧进行中,取两者中持续时间较长的(避免短请求截断较长的冻帧)。
/// </summary>
/// <param name="frames">冻帧帧数fixedDeltaTime 单位。0 或负数无效。</param>
public void FreezeFrames(int frames)
{
if (frames <= 0) return;
FreezeDuration(frames * Time.fixedDeltaTime);
}
/// <summary>
/// 冻帧指定时长Unscaled 实际秒数)。
/// 若已有冻帧进行中,取两者中较长的。
/// </summary>
/// <param name="unscaledSeconds">实际时长(秒),不受 timeScale 影响。</param>
public void FreezeDuration(float unscaledSeconds)
{
if (unscaledSeconds <= 0f) return;
float newEndTime = Time.unscaledTime + unscaledSeconds;
// 已有更长的冻帧进行中,放弃短请求,避免截断
if (_activeRoutine != null && newEndTime <= _freezeEndTime) return;
_freezeEndTime = newEndTime;
if (_activeRoutine != null)
StopCoroutine(_activeRoutine);
_activeRoutine = StartCoroutine(FreezeRoutine(unscaledSeconds));
}
// ── 内部实现 ──────────────────────────────────────────────────────
private IEnumerator FreezeRoutine(float unscaledSeconds)
{
Time.timeScale = 0f;
yield return new WaitForSecondsRealtime(unscaledSeconds);
Time.timeScale = _baseTimeScale;
_activeRoutine = null;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9aace2ae37bf9d0459a4cdeb34595885
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,40 @@
using UnityEngine;
namespace BaseGames.Combat
{
/// <summary>
/// 追踪抛射物。初始以 Direction 发射后,每帧向目标转向,
/// 转向力由 <see cref="ProjectileConfigSO.HomingStrength"/> 控制。
/// </summary>
public class HomingProjectile : Projectile
{
private Transform _target;
/// <summary>注入追踪目标(由 ProjectileManager 在生成时调用)。</summary>
public void SetTarget(Transform t) => _target = t;
protected override void OnInitialized()
{
_rb.velocity = Direction * _config.Speed;
}
protected override void Update()
{
base.Update();
if (_target == null || _config == null) return;
Vector2 toTarget = ((Vector2)_target.position - _rb.position).normalized;
_rb.velocity += toTarget * (_config.HomingStrength * Time.deltaTime);
float maxSpeed = _config.Speed * 1.5f;
if (_rb.velocity.sqrMagnitude > maxSpeed * maxSpeed)
_rb.velocity = _rb.velocity.normalized * maxSpeed;
}
protected override void OnDisable()
{
base.OnDisable();
_target = null;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: cb4a2de7e8deb224db43f89dbe62748d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -12,10 +12,11 @@ namespace BaseGames.Combat
public class HurtBox : MonoBehaviour
{
// ── 伤害接受方Awake 注入)──────────────────────────────────────────
private IDamageable _owner;
private IShieldable _shieldable; // 由 PlayerController.Awake() 注入
private ParrySystem _parrySystem; // Phase 2 由 PlayerController.Awake() 注入
private IPoiseSource _poiseSource; // Phase 2 由 EnemyBase.Awake() 注入
private IDamageable _owner;
private IShieldable _shieldable; // 由 PlayerController.Awake() 注入
private ParrySystem _parrySystem; // 由 PlayerController.Awake() 注入
private IPoiseSource _poiseSource; // 由 EnemyBase.Awake() 注入
private IStatusEffectable _statusEffectable; // Awake 缓存,避免每次受击调用 GetComponent
private bool _isHurtBoxInvincible;
private bool _isActive = true;
@@ -30,10 +31,18 @@ namespace BaseGames.Combat
public void SetPoiseSource(IPoiseSource src) => _poiseSource = src;
public void SetInvincible(bool value) => _isHurtBoxInvincible = value;
public void SetActive(bool value) => _isActive = value;
#if UNITY_EDITOR
// 付给编辑器的只读属性——避免反射并限制编辑器与运行时字段名耐合性。
public object EditorOwner => _owner;
public object EditorShieldable => _shieldable;
public object EditorParrySystem => _parrySystem;
public object EditorPoiseSource => _poiseSource;
public object EditorStatusEffectable => _statusEffectable;
#endif
private void Awake()
{
_owner = GetComponentInParent<IDamageable>();
_statusEffectable = GetComponentInParent<IStatusEffectable>();
if (_owner == null)
Debug.LogWarning($"[HurtBox] {name}: 父节点中未找到 IDamageable 实现。", this);
}
@@ -50,12 +59,12 @@ namespace BaseGames.Combat
if ((_owner.IsInvincible || _isHurtBoxInvincible)
&& !info.Flags.HasFlag(DamageFlags.IgnoreIFrame)) return;
// 2. 弹反检查(Phase 1 _parrySystem == null 跳过)
// 2. 弹反检查_parrySystem == null 跳过)
// ParrySystem 只暴露窗口状态,伤害数据留在 Combat 层,无跨程序集数据依赖。
if (_parrySystem != null && info.Flags.HasFlag(DamageFlags.CanBeParried))
if (_parrySystem.ConsumeParry()) return;
// 3. 霸体检查(Phase 1 _poiseSource == null 跳过)
// 3. 霸体检查_poiseSource == null 跳过)
if (!info.Flags.HasFlag(DamageFlags.ForceBreak) && _poiseSource != null)
{
PoiseLevel curPoise = _poiseSource.GetCurrentPoiseLevel();
@@ -96,11 +105,23 @@ namespace BaseGames.Combat
});
// 8. 状态效果触发DoT — Fire / Poison
// 使用接口避免对 StatusEffects 程序集的直接依赖
if (_owner is UnityEngine.MonoBehaviour mb)
{
mb.GetComponent<IStatusEffectable>()?.ApplyStatusEffect(info.Type);
}
// _statusEffectable 已在 Awake 中缓存,无需每次受击调用 GetComponent
_statusEffectable?.ApplyStatusEffect(info.Type);
}
#if UNITY_EDITOR
private void OnDrawGizmos()
{
var col = GetComponent<Collider2D>();
if (col == null) return;
// 激活时红色不透明,无敌/非激活时半透明
Gizmos.color = (_isActive && !_isHurtBoxInvincible)
? new UnityEngine.Color(1f, 0f, 0f, 0.45f)
: new UnityEngine.Color(1f, 0f, 0f, 0.1f);
Gizmos.DrawCube(col.bounds.center, col.bounds.size);
Gizmos.color = new UnityEngine.Color(1f, 0f, 0f, 0.9f);
Gizmos.DrawWireCube(col.bounds.center, col.bounds.size);
}
#endif
}
}

View File

@@ -0,0 +1,11 @@
// Assets/Scripts/Combat/IClashService.cs
// 拼刀服务接口,通过 ServiceLocator 注册与查询。
// ClashResolver 实现此接口HitBox 等调用方通过接口解耦。
namespace BaseGames.Combat
{
public interface IClashService
{
void ResolveClash(HitBox hitBoxA, HitBox hitBoxB);
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7df722a95e7dc7f4b808256877e44af7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,17 @@
using UnityEngine;
namespace BaseGames.Combat
{
/// <summary>
/// 抛射物服务接口。通过 ServiceLocator 注册,供敌人 AI 生成追踪弹使用。
/// </summary>
public interface IProjectileService
{
/// <summary>当前缓存的玩家 Transform生成追踪弹时注入目标。</summary>
Transform PlayerTransform { get; }
/// <summary>完整初始化一枚 HomingProjectile 并注入追踪目标。</summary>
void LaunchHoming(HomingProjectile proj, Vector2 direction,
ProjectileConfigSO config, DamageInfo damageInfo);
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b7d8b713a91da1d408f02c68e666f8fa
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,13 @@
namespace BaseGames.Combat
{
/// <summary>
/// 直线抛射物。以固定速度沿 Direction 方向飞行,无重力。
/// </summary>
public class LinearProjectile : Projectile
{
protected override void OnInitialized()
{
_rb.velocity = Direction * _config.Speed;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8e7b0c1c571010c4c9f65f953274086d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,58 @@
using UnityEngine;
using BaseGames.Parry;
namespace BaseGames.Combat
{
/// <summary>
/// 可被玩家弹反的抛射物。
/// 触发时优先检测弹反窗口;若成功弹反则反向飞行并可切换伤害源;
/// 否则走正常伤害流水线。
/// </summary>
public class ParryableProjectile : LinearProjectile
{
[SerializeField] private DamageSourceSO _reflectSource;
private bool _reflected;
protected override void OnInitialized()
{
// 禁用子 HitBox 的自动检测,改由本组件的 OnTriggerEnter2D 手动处理,
// 以便在命中前插入弹反判断。
_hitBox.Deactivate();
_rb.velocity = Direction * _config.Speed;
}
private void OnTriggerEnter2D(Collider2D other)
{
// ── 弹反判断 ────────────────────────────────────────────────
if (!_reflected)
{
var parrySystem = other.GetComponentInParent<ParrySystem>();
if (parrySystem != null && parrySystem.IsParrying && parrySystem.ConsumeParry())
{
_reflected = true;
Direction = -Direction;
_rb.velocity = Direction * _config.Speed * _config.ParrySpeedMultiplier;
if (_reflectSource != null)
DamageInfo = DamageInfo.From(_reflectSource);
return;
}
}
// ── 正常命中 ─────────────────────────────────────────────────
var hurtBox = other.GetComponent<HurtBox>();
if (hurtBox != null)
{
hurtBox.ReceiveDamage(DamageInfo);
ReturnToPool();
}
}
protected override void OnDisable()
{
base.OnDisable();
_reflected = false;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 075db832266507d40bc71389d6d3f333
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,31 @@
using System;
namespace BaseGames.Combat
{
/// <summary>
/// 描述某个状态/技能在特定动画时间段内拥有的霸体等级(架构 06_CombatModule §13
/// 在状态机的 Update() 或 AnimancerEvent 中与动画归一化时间对比,决定当前霸体等级。
/// 使用示例:
/// <code>
/// [SerializeField] private PoiseWindowConfig[] _poiseWindows;
/// public PoiseLevel GetCurrentPoiseLevel()
/// {
/// float t = _animancer.States.Current?.NormalizedTime ?? 0f;
/// foreach (var w in _poiseWindows)
/// if (t >= w.NormalizedStart && t <= w.NormalizedEnd)
/// return w.Level;
/// return PoiseLevel.None;
/// }
/// </code>
/// </summary>
[Serializable]
public struct PoiseWindowConfig
{
/// <summary>此时间窗口期间的霸体等级。</summary>
public PoiseLevel Level;
/// <summary>动画归一化时间起点0~1。</summary>
public float NormalizedStart;
/// <summary>动画归一化时间终点0~1。</summary>
public float NormalizedEnd;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: aaa68ed270c340743958655059e74cfd
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,67 @@
using UnityEngine;
using BaseGames.Core;
using BaseGames.Core.Pool;
namespace BaseGames.Combat
{
/// <summary>
/// 抛射物基类。子类通过重写 <see cref="OnInitialized"/> 设定初速度。
/// 依赖 <see cref="HitBox"/> 子组件进行碰撞伤害检测。
/// </summary>
[RequireComponent(typeof(Rigidbody2D), typeof(HitBox))]
public abstract class Projectile : MonoBehaviour
{
[HideInInspector] public DamageInfo DamageInfo;
[HideInInspector] public Vector2 Direction;
protected ProjectileConfigSO _config;
protected Rigidbody2D _rb;
protected HitBox _hitBox;
protected float _aliveTimer;
private PooledObject _pooledObject;
protected virtual void Awake()
{
_rb = GetComponent<Rigidbody2D>();
_hitBox = GetComponent<HitBox>();
_pooledObject = GetComponent<PooledObject>();
}
/// <summary>从对象池取出后的初始化入口。</summary>
public virtual void Initialize(ProjectileConfigSO config, DamageInfo damageInfo, Vector2 direction)
{
_config = config;
DamageInfo = damageInfo;
Direction = direction.normalized;
_aliveTimer = 0f;
_hitBox.Activate(config.DamageSource);
OnInitialized();
}
/// <summary>子类在此设定初速度或附加初始化逻辑。</summary>
protected virtual void OnInitialized() { }
protected virtual void Update()
{
_aliveTimer += Time.deltaTime;
if (_config != null && _aliveTimer >= _config.Lifetime)
ReturnToPool();
}
/// <summary>停用并归还对象池。</summary>
protected void ReturnToPool()
{
_hitBox.Deactivate();
gameObject.SetActive(false);
if (_pooledObject != null && _config != null)
ServiceLocator.GetOrDefault<IObjectPoolService>()?.Despawn(_config.PoolKey, _pooledObject);
}
protected virtual void OnDisable()
{
_aliveTimer = 0f;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b3be0438f9175ab4f989693a8b01fdc7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,28 @@
using UnityEngine;
namespace BaseGames.Combat
{
/// <summary>
/// 抛射物配置 ScriptableObject。描述一类抛射物的运动、伤害与对象池参数。
/// </summary>
[CreateAssetMenu(menuName = "Combat/ProjectileConfig")]
public class ProjectileConfigSO : ScriptableObject
{
[Header("伤害")]
public DamageSourceSO DamageSource;
[Header("运动")]
public float Speed = 12f;
public float Lifetime = 5f;
public float LaunchAngleDeg = 45f;
public float GravityScale = 1f;
public float HomingStrength = 4f;
[Header("对象池")]
public string PoolKey;
[Header("弹反")]
public float ParrySpeedMultiplier = 1.2f;
public float ParryDamageMultiplier = 2.0f;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 34d03fe23f5830b4e8abbe28bfbb5e52
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,55 @@
using UnityEngine;
using BaseGames.Core;
using BaseGames.Core.Events;
namespace BaseGames.Combat
{
/// <summary>
/// 抛射物管理器(单例 MonoBehaviour
/// 缓存玩家 Transform供追踪类抛射物注入目标引用。
/// </summary>
public class ProjectileManager : MonoBehaviour, IProjectileService
{
[SerializeField] private TransformEventChannelSO _onPlayerSpawned;
private Transform _playerTransform;
private readonly CompositeDisposable _subs = new();
/// <summary>当前缓存的玩家 Transform生成追踪弹时使用。</summary>
public Transform PlayerTransform => _playerTransform;
private void Awake()
{
if (ServiceLocator.GetOrDefault<IProjectileService>() != null) { Destroy(gameObject); return; }
ServiceLocator.Register<IProjectileService>(this);
}
private void OnDestroy()
{
ServiceLocator.Unregister<IProjectileService>(this);
}
private void OnEnable()
{
_onPlayerSpawned?.Subscribe(OnPlayerSpawned).AddTo(_subs);
}
private void OnDisable()
{
_subs.Clear();
}
private void OnPlayerSpawned(Transform player) => _playerTransform = player;
/// <summary>
/// 完整初始化一枚 <see cref="HomingProjectile"/> 并注入追踪目标。
/// </summary>
public void LaunchHoming(HomingProjectile proj, Vector2 direction,
ProjectileConfigSO config, DamageInfo damageInfo)
{
if (proj == null || config == null) return;
proj.Initialize(config, damageInfo, direction);
proj.SetTarget(_playerTransform);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ac14f526cd2afcd4d8cdd44780805472
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,20 +1,137 @@
using UnityEngine;
using System;
using BaseGames.Core.Events;
namespace BaseGames.Combat
{
/// <summary>
/// 护盾组件Phase 1 存根)。实现 IShieldable 接口供 HurtBox 注入。
/// Phase 2 实现完整护盾逻辑(护盾值、再生、破盾事件)
/// 护盾组件。实现 IShieldable 接口供 HurtBox 注入。
/// 护盾参数通过 ShieldConfigSO 集中配置
/// </summary>
public class ShieldComponent : MonoBehaviour, IShieldable
{
public bool HasShield { get; private set; }
[Header("配置资产")]
[SerializeField] private ShieldConfigSO _config;
[Header("VFX 事件频道")]
[SerializeField] private VoidEventChannelSO _onShieldBrokenChannel; // 护盾破碎 → 播放破碎 VFX
[SerializeField] private VoidEventChannelSO _onShieldRestoredChannel; // 护盾恢复 → 播放恢复 VFX
// ── 运行时属性 ────────────────────────────────────────────────────────
public int MaxShieldHP => _config.MaxShieldHP;
public int CurrentShieldHP { get; private set; }
/// <summary>当前是否能吸收伤害(护盾 HP > 0 且不在破碎惩罚期)。</summary>
public bool HasShield => CurrentShieldHP > 0 && _brokenPenaltyTimer <= 0f;
private float AbsorptionRatio => _config.DamageAbsorptionRatio;
private float RechargeDelay => _config.RechargeDelay;
private float RechargeRate => _config.RechargeRate;
private float BrokenPenaltyDur => _config.BrokenPenaltyDuration;
private float ParryRestoreRatio => _config.ParryRestoreRatio;
public event Action<int, int> OnShieldChanged; // (current, max)
public event Action OnShieldBroken;
private float _regenDelayTimer;
private float _brokenPenaltyTimer;
private void Awake()
{
Debug.Assert(_config != null, "[ShieldComponent] _config 未赋值,请在 Inspector 中指定 ShieldConfigSO。", this);
CurrentShieldHP = MaxShieldHP;
}
private void Update()
{
int maxHP = MaxShieldHP;
if (maxHP <= 0) return;
// 破碎惩罚计时
if (_brokenPenaltyTimer > 0f)
{
_brokenPenaltyTimer -= Time.deltaTime;
// 破碎惩罚结束 → 广播护盾恢复事件VFX 钩子)
if (_brokenPenaltyTimer <= 0f)
_onShieldRestoredChannel?.Raise();
return;
}
if (CurrentShieldHP >= maxHP) return;
if (RechargeRate <= 0f) return;
if (_regenDelayTimer > 0f)
{
_regenDelayTimer -= Time.deltaTime;
return;
}
int prev = CurrentShieldHP;
CurrentShieldHP = Mathf.Min(CurrentShieldHP + Mathf.CeilToInt(RechargeRate * Time.deltaTime), maxHP);
if (CurrentShieldHP != prev)
OnShieldChanged?.Invoke(CurrentShieldHP, maxHP);
}
/// <summary>
/// 尝试以护盾吸收伤害。
/// 返回穿透量0 = 全部吸收,>0 = 穿透量继续走 TakeDamage 流程)。
/// Phase 1护盾不存在全量穿透。
/// 尝试以护盾吸收伤害。返回穿透量0=全部吸收,>0=穿透量继续走 TakeDamage 流程)。
/// </summary>
public int AbsorbDamage(int amount) => amount;
public int AbsorbDamage(int amount)
{
if (!HasShield) return amount;
_regenDelayTimer = RechargeDelay;
int maxHP = MaxShieldHP;
// 按吸收比例计算实际由护盾承担的伤害
int toAbsorb = Mathf.FloorToInt(amount * AbsorptionRatio);
toAbsorb = Mathf.Min(toAbsorb, CurrentShieldHP);
int passthrough = amount - toAbsorb;
CurrentShieldHP -= toAbsorb;
OnShieldChanged?.Invoke(CurrentShieldHP, maxHP);
if (CurrentShieldHP <= 0)
{
CurrentShieldHP = 0;
_brokenPenaltyTimer = BrokenPenaltyDur;
OnShieldBroken?.Invoke();
_onShieldBrokenChannel?.Raise(); // VFX 钩子:播放护盾破碎特效
}
return passthrough;
}
/// <summary>存档点 / 复活时调用:完全恢复护盾并清除惩罚状态。</summary>
public void FullRecharge()
{
bool wasBroken = _brokenPenaltyTimer > 0f || CurrentShieldHP <= 0;
_brokenPenaltyTimer = 0f;
_regenDelayTimer = 0f;
CurrentShieldHP = MaxShieldHP;
OnShieldChanged?.Invoke(CurrentShieldHP, MaxShieldHP);
if (wasBroken)
_onShieldRestoredChannel?.Raise(); // VFX 钩子:护盾已满血恢复
}
/// <summary>弹反成功时调用:按 ParryRestoreRatio 恢复护盾(会清除惩罚状态)。</summary>
public void OnParrySuccess()
{
int maxHP = MaxShieldHP;
if (maxHP <= 0) return;
_brokenPenaltyTimer = 0f;
_regenDelayTimer = 0f;
int restore = Mathf.CeilToInt(maxHP * ParryRestoreRatio);
CurrentShieldHP = Mathf.Min(CurrentShieldHP + restore, maxHP);
OnShieldChanged?.Invoke(CurrentShieldHP, maxHP);
}
/// <summary>Inspector / 道具系统调用:设置最大护盾并重置当前值。</summary>
public void SetMaxShieldHP(int max)
{
_maxShieldHP = Mathf.Max(0, max);
_brokenPenaltyTimer = 0f;
CurrentShieldHP = MaxShieldHP;
OnShieldChanged?.Invoke(CurrentShieldHP, MaxShieldHP);
}
}
}

View File

@@ -0,0 +1,29 @@
using UnityEngine;
namespace BaseGames.Combat
{
/// <summary>
/// 护盾配置资产(架构 06_CombatModule §6 ShieldSystem
/// 可通过 Assets/Create/Combat/ShieldConfig 创建。
/// </summary>
[CreateAssetMenu(menuName = "Combat/ShieldConfig", fileName = "ShieldConfig")]
public class ShieldConfigSO : ScriptableObject
{
[Header("护盾数值")]
public int MaxShieldHP = 0; // 0 = 无护盾(默认禁用)
[Range(0f, 1f)]
public float DamageAbsorptionRatio = 1.0f; // 1.0 = 全吸收;<1.0 = 部分穿透
public float RechargeDelay = 2.0f; // 受击后延迟多久才开始再生(秒)
public float RechargeRate = 20f; // 每秒再生 HP
[Header("破碎惩罚")]
public float BrokenPenaltyDuration = 3.0f; // 护盾破碎后不可再生的惩罚时长(秒)
[Header("存档点")]
public bool FullRechargeOnSavePoint = true; // 抵达存档点时是否完全恢复护盾
[Header("弹反加成")]
[Range(0f, 1f)]
public float ParryRestoreRatio = 0.3f; // 弹反成功后恢复护盾比例(占 MaxShieldHP
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b2e617a47cdc4b64383a6a907023b3e7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,48 @@
using UnityEngine;
using BaseGames.Combat;
namespace BaseGames.Combat
{
/// <summary>
/// 技能 HitBox 实例(架构 09_ProgressionModule §9 SkillHitBoxInstance
/// 挂载于技能 HitBox Prefab 根节点。
/// 命名规范: Assets/Prefabs/Skills/SKL_{skillId}_HitBox.prefab
///
/// Prefab 内部层级示例(近战 AoE 技能):
/// [SKL_SkySlash_HitBox]
/// └── [HitBox] ← 扇形/圆形 PolygonCollider2D
/// └── HitBox.cs
/// </summary>
public class SkillHitBoxInstance : MonoBehaviour
{
[SerializeField] private HitBox[] _hitBoxes; // 技能可有多个 HitBox多段伤害
public event System.Action<DamageInfo> OnHitConfirmed;
private void Awake()
{
foreach (var hb in _hitBoxes)
{
if (hb == null) continue;
hb.OnHitConfirmed += info => OnHitConfirmed?.Invoke(info);
}
}
/// <summary>激活所有 HitBox传入伤害数据源和攻击者 Transform。</summary>
public void Activate(DamageSourceSO source, Transform attacker)
{
foreach (var hb in _hitBoxes)
hb?.Activate(source, attacker);
}
/// <summary>duration 秒后自动销毁此 GameObject。</summary>
public void AutoDestroyAfter(float duration)
=> Destroy(gameObject, Mathf.Max(0f, duration));
private void OnDestroy()
{
foreach (var hb in _hitBoxes)
hb?.Deactivate();
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 776f1908eabcca546937010169b91efc
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -8,7 +8,8 @@
"versionDefines": [],
"rootNamespace": "BaseGames.Combat.StatusEffects",
"references": [
"BaseGames.Combat"
"BaseGames.Combat",
"BaseGames.Core.Events"
],
"autoReferenced": true,
"overrideReferences": false,

View File

@@ -0,0 +1,45 @@
namespace BaseGames.Combat.StatusEffects
{
/// <summary>
/// 燃烧效果(架构 06_CombatModule §11
/// 规则不可叠加MaxStacks = 1重复施加刷新持续时间每 0.5 秒造成 1 点 True 伤害。
/// </summary>
public class FireEffect : StatusEffect
{
private const float BaseDuration = 3.0f; // 持续 3 秒
private const float DotInterval = 0.5f; // 每 0.5 秒一次
public override StatusEffectType EffectType => StatusEffectType.Fire;
public override int MaxStacks => 1;
public FireEffect()
{
TickInterval = DotInterval;
}
public override void OnApply(StatusEffectManager owner)
{
base.OnApply(owner);
owner.SetShaderParam("_FireGlow", 1f);
}
/// <summary>每次 DoT Tick 造成 1 点 True 伤害(绕过护盾,无敌帧不免疫)。</summary>
public override void OnTick()
{
var info = new DamageInfo.Builder()
.SetRaw(1)
.SetType(DamageType.True)
.SetFlags(DamageFlags.IgnoreIFrame)
.Build();
Owner?.ApplyDirectDamage(info);
}
public override void OnExpire()
{
Owner?.SetShaderParam("_FireGlow", 0f);
}
protected override float GetBaseDuration() => BaseDuration;
public override string GetDisplayName() => "燃烧";
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2d692ae17737ac54bb0940c3487a59be
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,54 @@
namespace BaseGames.Combat.StatusEffects
{
/// <summary>
/// 中毒效果(架构 06_CombatModule §11
/// 规则:最多叠加 3 层;每层叠加伤害 +1每 1 秒造成 StackCount 点 True 伤害。
/// </summary>
public class PoisonEffect : StatusEffect
{
private const float BaseDuration = 5.0f; // 持续 5 秒
private const float DotInterval = 1.0f; // 每 1 秒一次
public override StatusEffectType EffectType => StatusEffectType.Poison;
public override int MaxStacks => 3;
public PoisonEffect()
{
TickInterval = DotInterval;
}
public override void OnApply(StatusEffectManager owner)
{
base.OnApply(owner);
UpdateShader();
}
public override void OnStack()
{
base.OnStack();
UpdateShader();
}
public override void OnTick()
{
var info = new DamageInfo.Builder()
.SetRaw(StackCount) // 叠层越多伤害越高
.SetType(DamageType.True)
.SetFlags(DamageFlags.IgnoreIFrame)
.Build();
Owner?.ApplyDirectDamage(info);
}
public override void OnExpire()
{
StackCount = 0;
Owner?.SetShaderParam("_PoisonGlow", 0f);
}
private void UpdateShader()
=> Owner?.SetShaderParam("_PoisonGlow", StackCount / (float)MaxStacks);
protected override float GetBaseDuration() => BaseDuration;
public override string GetDisplayName() => $"中毒 ×{StackCount}";
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 97257dbfc13b78441a89c652f51310cf
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,33 @@
namespace BaseGames.Combat.StatusEffects
{
/// <summary>
/// 硬直效果(架构 06_CombatModule §11
/// 规则不可叠加MaxStacks = 1施加期间宿主无法执行动作。
/// 外部查询entity.GetComponent&lt;StatusEffectManager&gt;()?.HasEffect(StatusEffectType.Stagger)
/// </summary>
public class StaggerEffect : StatusEffect
{
private const float BaseDuration = 0.5f; // 默认硬直持续时间(秒)
private readonly float _overrideDuration;
/// <param name="duration">自定义持续时间(&lt;= 0 使用默认值)。</param>
public StaggerEffect(float duration = 0f)
{
_overrideDuration = duration > 0f ? duration : BaseDuration;
TickInterval = 0f; // 无 DoT Tick
}
public override StatusEffectType EffectType => StatusEffectType.Stagger;
public override int MaxStacks => 1;
public override void OnApply(StatusEffectManager owner)
{
Owner = owner;
Duration = _overrideDuration;
}
protected override float GetBaseDuration() => _overrideDuration;
public override string GetDisplayName() => "硬直";
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 23c67d6324b88a1468bd20baa4f109c3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,91 @@
using UnityEngine;
namespace BaseGames.Combat.StatusEffects
{
/// <summary>
/// 状态效果抽象基类(架构 06_CombatModule §11
/// ⚠️ 类名为 StatusEffect非 StatusEffectBase
///
/// 生命周期:
/// OnApply(owner) → Update(delta) × N [内部调用 OnTick()] → OnExpire()
///
/// 叠加规则:同类型再次施加时调用 OnStack()Manager 保证每种类型只有一个实例。
/// </summary>
public abstract class StatusEffect
{
/// <summary>效果类型标识(用作 Dictionary key。</summary>
public abstract StatusEffectType EffectType { get; }
/// <summary>最大叠加层数1 = 不可叠加,重复施加只刷新持续时间)。</summary>
public abstract int MaxStacks { get; }
/// <summary>当前叠加层数。</summary>
public int StackCount { get; protected set; } = 1;
/// <summary>当前剩余持续时间(秒)。</summary>
public float Duration { get; protected set; }
/// <summary>每次 Tick 的间隔(秒)。</summary>
public float TickInterval { get; protected set; }
/// <summary>是否已过期(由 Manager 每帧检查)。</summary>
public virtual bool IsExpired => Duration <= 0f;
private float _tickTimer;
/// <summary>宿主 ManagerOnApply 时注入OnTick/OnExpire 中可访问)。</summary>
public StatusEffectManager Owner { get; protected set; }
// ── 生命周期回调(可重写)─────────────────────────────────────────
/// <summary>
/// 效果施加时调用Owner 在此注入)。
/// ⚠️ 参数为 StatusEffectManager非 IDamageable架构 06 §11。
/// </summary>
public virtual void OnApply(StatusEffectManager owner)
{
Owner = owner;
Duration = GetBaseDuration();
}
/// <summary>
/// 同类型效果再次施加时调用(叠层 / 刷新持续时间)。
/// 默认行为:刷新持续时间并叠加层数(若未达上限)。
/// </summary>
public virtual void OnStack()
{
Duration = GetBaseDuration();
StackCount = Mathf.Min(StackCount + 1, MaxStacks);
}
/// <summary>每个 TickInterval 秒调用一次DoT 等周期效果)。</summary>
public virtual void OnTick() { }
/// <summary>效果到期 / 被净化时调用。⚠️ 名称 OnExpire非 OnRemove。</summary>
public virtual void OnExpire() { }
// ── 框架驱动(由 Manager.Update 调用,每帧执行)──────────────────
/// <summary>递减持续时间并在到达 Tick 间隔时触发 OnTick。</summary>
public void Update(float delta)
{
Duration -= delta;
if (TickInterval <= 0f) return;
_tickTimer += delta;
if (_tickTimer >= TickInterval)
{
_tickTimer -= TickInterval;
OnTick();
}
}
// ── 子类必须实现 ──────────────────────────────────────────────────
/// <summary>返回本类型效果的基础持续时间(秒)。</summary>
protected abstract float GetBaseDuration();
/// <summary>返回本效果的本地化显示名称。</summary>
public abstract string GetDisplayName();
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4490046c0c848054e9a6846dc272f9f6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,21 @@
using UnityEngine;
using BaseGames.Core.Events;
namespace BaseGames.Combat.StatusEffects
{
/// <summary>状态效果事件(应用 / 到期时广播,可用于 UI 更新)。</summary>
public struct StatusEffectEvent
{
/// <summary>效果类型。</summary>
public StatusEffectType EffectType;
/// <summary>当前叠加层数(到期时为 0。</summary>
public int StackCount;
/// <summary>剩余持续时间(到期时为 0。</summary>
public float RemainingDuration;
}
[CreateAssetMenu(menuName = "Events/StatusEffect")]
public class StatusEffectEventChannelSO : BaseEventChannelSO<StatusEffectEvent> { }
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e2aa7161466b28442acab31ae092166b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,20 +1,176 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using BaseGames.Combat;
namespace BaseGames.Combat.StatusEffects
{
/// <summary>
/// 状态效果管理器Phase 1 桩)
/// 实现 IStatusEffectable 接口,由 HurtBox 通过接口调用,避免程序集循环依赖。
/// Phase 2 实现完整的效果叠加、持续时间、DoT 伤害计算。
/// 状态效果管理器。
///
/// 架构 06_CombatModule §11
/// - 双结构ListUpdate 遍历)+ DictionaryO(1) 类型查找)
/// - 实现 IStatusEffectable接受来自 HurtBox 的 DamageType 并映射到具体效果
/// - DealDotDamageStatusEffect 子类通过 Owner 调用,绕过无敌帧造成 DoT
/// - CleanseEffect净化指定类型效果道具/技能使用)
/// </summary>
[RequireComponent(typeof(SpriteRenderer))]
public class StatusEffectManager : MonoBehaviour, IStatusEffectable
{
// Phase 1空实现
public void ApplyStatusEffect(DamageType type) { }
}
[Header("事件频道(可选)")]
[SerializeField] private StatusEffectEventChannelSO _onStatusEffectApplied;
[SerializeField] private StatusEffectEventChannelSO _onStatusEffectExpired;
// ── Phase 1 占位效果类型 ──────────────────────────────────────────────────
public class FireEffect { }
public class PoisonEffect { }
// ── 双结构 ─────────────────────────────────────────────────────────
private readonly List<StatusEffect> _activeList = new();
private readonly Dictionary<StatusEffectType, StatusEffect> _activeIndex = new();
// ── Shader 渲染MaterialPropertyBlock不修改共享材质─────────
private SpriteRenderer _renderer;
private MaterialPropertyBlock _propBlock;
// ── DoT 伤害代理(由 StatusEffect.OnTick 通过 Owner 调用)──────────
private IDamageable _damageable;
// ── 效果工厂字典(可在 Awake 后动态注册)─────────────────────
private readonly Dictionary<DamageType, Func<StatusEffect>> _effectFactories = new();
private void Awake()
{
_renderer = GetComponent<SpriteRenderer>();
_propBlock = new MaterialPropertyBlock();
_damageable = GetComponentInParent<IDamageable>();
// 默认标准效果注册(子类或外部模块可调用 RegisterEffectFactory 覆盖或扩展)
RegisterEffectFactory(DamageType.Fire, () => new FireEffect());
RegisterEffectFactory(DamageType.Poison, () => new PoisonEffect());
}
private void Update()
{
float delta = Time.deltaTime;
// 逆序遍历,避免移除时索引错位
for (int i = _activeList.Count - 1; i >= 0; i--)
{
StatusEffect effect = _activeList[i];
effect.Update(delta);
if (effect.IsExpired)
RemoveAt(i, effect);
}
}
// ── IStatusEffectable 实现 ─────────────────────────────────────────
/// <summary>
/// HurtBox 调用入口:将 DamageType 映射为具体 StatusEffect 实例并施加。
/// </summary>
public void ApplyStatusEffect(DamageType type)
{
StatusEffect effect = CreateEffect(type);
if (effect != null)
ApplyEffect(effect);
}
/// <summary>
/// 注册或覆盖一个 DamageType 对应的效果工厂。
/// Boss 或特殊游玩法式可在运行时注册自定义效果。
/// </summary>
public void RegisterEffectFactory(DamageType type, Func<StatusEffect> factory)
=> _effectFactories[type] = factory;
// ── 公开 API ───────────────────────────────────────────────────────
/// <summary>直接施加一个具体效果(供技能/Boss 使用)。</summary>
public void ApplyEffect(StatusEffect effect)
{
if (_activeIndex.TryGetValue(effect.EffectType, out StatusEffect existing))
{
existing.OnStack();
BroadcastApplied(existing);
}
else
{
effect.OnApply(this);
_activeList.Add(effect);
_activeIndex[effect.EffectType] = effect;
BroadcastApplied(effect);
}
}
/// <summary>净化指定类型效果(净化道具/技能调用)。</summary>
public void CleanseEffect(StatusEffectType type)
{
if (!_activeIndex.TryGetValue(type, out StatusEffect effect)) return;
effect.OnExpire();
_activeIndex.Remove(type);
_activeList.Remove(effect);
BroadcastExpired(effect);
}
/// <summary>查询是否存在指定类型效果(供状态机/UI 轮询)。</summary>
public bool HasEffect(StatusEffectType type) => _activeIndex.ContainsKey(type);
/// <summary>获取指定类型效果(可为 null。</summary>
public StatusEffect GetEffect(StatusEffectType type)
=> _activeIndex.TryGetValue(type, out var e) ? e : null;
/// <summary>
/// DoT 伤害代理(架构 06 §10。StatusEffect.OnTick() 调用此方法,传入已构建好的 DamageInfo。
/// </summary>
public void ApplyDirectDamage(DamageInfo info)
{
_damageable?.TakeDamage(info);
}
/// <summary>设置 Sprite Shader 参数MaterialPropertyBlock不修改共享材质。</summary>
public void SetShaderParam(string param, float value)
{
if (_renderer == null) return;
_renderer.GetPropertyBlock(_propBlock);
_propBlock.SetFloat(param, value);
_renderer.SetPropertyBlock(_propBlock);
}
/// <summary>净化所有状态效果(存档点激活 / 返回城镇等调用)。</summary>
public void CleanseAll()
{
foreach (var e in _activeList) e.OnExpire();
_activeList.Clear();
_activeIndex.Clear();
}
// ── 私有辅助 ───────────────────────────────────────────────────────
private StatusEffect CreateEffect(DamageType type)
=> _effectFactories.TryGetValue(type, out var factory) ? factory() : null;
private void RemoveAt(int index, StatusEffect effect)
{
effect.OnExpire();
_activeList.RemoveAt(index);
_activeIndex.Remove(effect.EffectType);
BroadcastExpired(effect);
}
private void BroadcastApplied(StatusEffect effect)
{
_onStatusEffectApplied?.Raise(new StatusEffectEvent
{
EffectType = effect.EffectType,
StackCount = effect.StackCount,
RemainingDuration = effect.Duration,
});
}
private void BroadcastExpired(StatusEffect effect)
{
_onStatusEffectExpired?.Raise(new StatusEffectEvent
{
EffectType = effect.EffectType,
StackCount = 0,
RemainingDuration = 0f,
});
}
}
}

View File

@@ -0,0 +1,15 @@
namespace BaseGames.Combat.StatusEffects
{
/// <summary>
/// 状态效果类型枚举(架构 06_CombatModule §11
/// 用于状态机索引Dictionary key和事件载荷与 DamageType 相互独立。
/// </summary>
public enum StatusEffectType
{
Fire, // 燃烧DoT不可叠加重复施加刷新持续时间
Poison, // 中毒DoT最多 3 层叠加)
Stagger, // 硬直(无法行动 N 秒)
Freeze, // 冻结(减速 / 固化)
Stun, // 眩晕(无法行动)
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9a5502c061dc69b4b82113ed5b282740
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,3 +0,0 @@
// Placeholder to prevent asmdef-no-scripts warning.
namespace BaseGames.Combat.StatusEffects { }

View File

@@ -1,3 +0,0 @@
// Placeholder to prevent asmdef-no-scripts warning.
namespace BaseGames.Combat { }