Files
zeling_v2/Docs/Architecture/07_EnemyModule.md
2026-05-08 11:04:00 +08:00

44 KiB
Raw Permalink Blame History

07 · 敌人模块

命名空间 BaseGames.EnemiesBaseGames.Enemies.AIBaseGames.Enemies.NavigationBaseGames.Enemies.Boss.Patterns
程序集 见各 asmdef
路径 Assets/Scripts/Enemies/
依赖 BaseGames.CombatKybernetik.AnimancerBehaviorDesigner.RuntimePathBerserker2d


目录

  1. EnemyBase
  2. EnemyStats
  3. EnemyMovement
  4. EnemyCombat
  5. EnemyNavAgent
  6. EnemyStatsSO
  7. EnemyAnimationConfigSO
  8. Behavior Designer 任务类目录
  9. BossBase
  10. BossOrchestrator
  11. AttackPatternSO 与 TelegraphSystem
  12. DeathShade死亡遗骸
  13. Enemy Prefab 层级结构
  14. LootTableSO 与 LootResolver
  15. BatchLOSSystem — Burst/Jobs 批量视线检测

1. EnemyBase

// 路径: Assets/Scripts/Enemies/EnemyBase.cs
// 实现 IDamageable 接口(定义于 BaseGames.Combat
public class EnemyBase : MonoBehaviour, IDamageable
{
    [Header("Config")]
    [SerializeField] protected EnemyStatsSO         _statsSO;
    [SerializeField] protected EnemyAnimationConfigSO _animConfig;

    [Header("Sub ComponentsInspector 引用,同 Prefab")]
    [SerializeField] protected EnemyStats       _stats;
    [SerializeField] protected EnemyMovement    _movement;
    [SerializeField] protected EnemyCombat      _combat;
    [SerializeField] protected EnemyNavAgent    _nav;
    [SerializeField] protected AnimancerComponent _animancer;
    [SerializeField] protected EnemyFeedback    _feedback;
    [SerializeField] protected HurtBox          _hurtBox;
    [SerializeField] protected StatusEffectManager _statusEffects;
    [SerializeField] protected PoiseSystem      _poise;
    [SerializeField] protected LootTableSO      _lootTable;    // 战利品掉落表

    [Header("Event Channels - Raise")]
    [SerializeField] private TransformEventChannelSO _onEnemyDied;

    // ──── 缓存组件Awake 初始化,消除热路径 GetComponent ─────────────────────
    private BehaviorTree _behaviorTree;   // GetComponent<BehaviorTree>() 缓存Awake 写入

    // ──── IDamageable 实现 ───────────────────────────────────────────────
    public bool  IsInvincible => _currentState == EnemyStateType.Dead;
    public bool  IsDead       => _currentState == EnemyStateType.Dead;
    public bool  IsStaggered  => _currentState == EnemyStateType.Stagger;
    public int   Defense      => _stats.Defense;
    public void  TakeDamage(DamageInfo info);
    // 1. 霸体检查_poise.TryBreak(info)
    // 2. _stats.CurrentHP -= info.FinalDamage
    // 3. 若 HP ≤ 0 → Die()
    // 4. 若霸体被打断 → ForceState(EnemyStateType.Stagger)
    // 5. 否则 → ForceState(EnemyStateType.Hurt)(短硬直)

    // ──── 状态控制 ──────────────────────────────────────────────────────
    public EnemyStateType CurrentState { get; private set; }
    public void ForceState(EnemyStateType newState);

    // ──── BD 行为树接口(虚方法,子类可覆盖)──────────────────────────
    public virtual void MoveTo(Vector2 target)           => _nav.RequestMoveTo(target);
    public virtual void MoveInDirection(float dir)       => _movement.MoveHorizontal(dir);
    public virtual void StopMovement()                   => _nav.StopNavigation();
    public virtual void BeginAttack(AttackType type)     => _combat.StartAttack(type);
    public virtual bool CanAttack()                      => _stats.AttackCooldownTimer <= 0;
    public virtual bool IsPlayerInRange(float range)     => _stats.DistanceToPlayer <= range;
    public virtual bool IsPlayerVisible()                => CheckLineOfSight();
    public virtual void FacePlayer()                     => _movement.FaceTarget(GetPlayerPosition());
    public virtual void JumpTo(Vector2 target)           => _movement.JumpToTarget(target);
    // ──── HurtBox 接口(受击击退,不由 BD 控制)────────────────────────
    // 由 HurtBox.ReceiveDamage 在伤害流程结束后调用(见 06_CombatModule §5
    public virtual void Knockback(DamageInfo info)
    {
        if (info.Flags.HasFlag(DamageFlags.NoKnockback)) return;
        _movement.ApplyKnockback(info.KnockbackDirection, info.KnockbackForce);
    }

    // ──── EnemyAnimationEvents 接口虚方法Boss 子类覆盖)──────────
    // 由 EnemyAnimationEvents.OnAnimationEvent 在对应动画帧触发时调用(见 24_AnimEventModule §6
    public virtual void SpawnProjectile(string data)     { }
    public virtual void TriggerPhaseTwo()                { }
    public virtual void OnAnimationComplete(string data) { }
    // ⚡ BD 每帧 Tick 调用 Blackboard 读写 SharedVariableAwake 缓存、消除 GetComponent 热开销
    public BehaviorTree Blackboard => _behaviorTree;

    // ──── 生死 ──────────────────────────────────────────────────────────
    protected virtual void Die();
    // 1. ForceState(EnemyStateType.Dead)
    // 2. 禁用所有碰撞体
    // 3. BehaviorTree.DisableBehavior()
    // 4. 播放死亡动画Animancer
    // 5. 掉落战利品 LootResolver.Resolve(_lootTable, transform.position)
    // 6. 掉落 GeoCollectible Spawn
    // 7. Raise _onEnemyDied广播 Transform
    // 8. 延迟归还到对象池 or 销毁

    // ──── 内部 ──────────────────────────────────────────────────────────
    // ⚠️ DEPRECATED: 以下 3 帧节流 LOS 实现已被 BatchLOSSystem 取代(见 §15
    // 在敌人实现 ILOSRequester 接口后_losCache/_losLastFrame 字段及
    // CheckLineOfSight() 方法应移除,改为读取 _losResult 字段(由 BatchLOSSystem 每帧写入)。
    // 保留此段仅供 <4 个活跃敌人的场景或 BatchLOSSystem 未挂载时降级回退使用。
    //
    // ─── 旧实现(保留作降级回退,不得在新敌人中直接使用)─────────────────────
    // [System.Obsolete("使用 ILOSRequester + BatchLOSSystem 替代(见 §15")]
    private bool _losCache;
    private int  _losLastFrame = -99;
    private const int LOS_REFRESH_INTERVAL = 3;

    // [System.Obsolete("使用 ILOSRequester + BatchLOSSystem 替代(见 §15")]
    private bool CheckLineOfSight()
    {
        if (Time.frameCount - _losLastFrame < LOS_REFRESH_INTERVAL)
            return _losCache;   // 返回缓存结果

        _losLastFrame = Time.frameCount;
        var playerPos = GetPlayerPosition();
        var origin    = (Vector2)transform.position;
        var dir       = (playerPos - origin).normalized;
        var dist      = Vector2.Distance(origin, playerPos);
        var hit = Physics2D.Raycast(origin, dir, dist, LayerMask.GetMask("Terrain"));
        _losCache = (hit.collider == null);
        return _losCache;
    }
    // ─── 新实现ILOSRequester 适配后使用此字段)─────────────────────────────
    // private bool _losResult;  // 由 BatchLOSSystem.ReceiveLOSResult() 写入
    // public virtual bool IsPlayerVisible() => _losResult; // 覆盖上方虚方法

    // 玩家位置来源——统一通过 PlayerPositionEventChannelSO 订阅更新到黑板 SharedVariable
    // 不直接引用 PlayerController保持模块边界隔离。
    // EnemyBase.Awake 中订阅 EVT_PlayerPositionUpdated ,将坐标写入 BD SharedVar “PlayerPosition”
    private Vector2 GetPlayerPosition()
        => ((SharedVector2)_behaviorTree.GetVariable("PlayerPosition")).Value;
}

public enum EnemyStateType { Controlled, Hurt, Stagger, Dead }
public enum AttackType     { Melee, Ranged, Special }

2. EnemyStats

// 路径: Assets/Scripts/Enemies/EnemyStats.cs
public class EnemyStats : MonoBehaviour
{
    private EnemyStatsSO _config;   // 由 EnemyBase.Awake 注入

    public int   MaxHP              { get; private set; }
    public int   CurrentHP          { get; private set; }
    public int   Defense            { get; private set; }
    public float AttackCooldownTimer { get; private set; }
    public float DistanceToPlayer   { get; set; }  // 每帧由 EnemyBase 更新

    public void Initialize(EnemyStatsSO so);
    public void TakeDamage(int amount);
    public void TickAttackCooldown(float dt);
    public void ResetAttackCooldown();

    // 存档集成
    public EnemySaveData GetSaveData();
    public void LoadSaveData(EnemySaveData data);
}

3. EnemyMovement

// 路径: Assets/Scripts/Enemies/EnemyMovement.cs
[RequireComponent(typeof(Rigidbody2D))]
public class EnemyMovement : MonoBehaviour
{
    [SerializeField] private EnemyStatsSO _config;

    private Rigidbody2D _rb;
    private int _facingDir = 1;

    public bool IsGrounded { get; private set; }

    public void MoveHorizontal(float dir);
    // dir: +1 右, -1 左, 0 停止
    // 速度 = dir * _config.WalkSpeed追击时用 RunSpeedBD 任务设置 speed 参数)

    public void MoveWithSpeed(float dir, float speed);   // BD 任务显式传速度

    public void FaceTarget(Vector2 targetPos);
    // SpriteRenderer.flipX = (targetPos.x < transform.position.x)

    public void ApplyKnockback(Vector2 dir, float force);
    public void StopHorizontal();

    private void FixedUpdate()
    {
        CheckGrounded();
    }
}

4. EnemyCombat

// 路径: Assets/Scripts/Enemies/EnemyCombat.cs
public class EnemyCombat : MonoBehaviour
{
    [SerializeField] private HitBox[] _hitBoxes;   // 按 AttackType 索引

    // 由 EnemyBase.BeginAttack 调用(通常由 AnimationEvent 控制 On/Off
    public void StartAttack(AttackType type);
    public void EnableHitBox(int index);
    public void DisableHitBox(int index);
    public void DisableAllHitBoxes();
}

5. IPathAgent — 导航中间件接口

P2 优化EnemyNavAgent 直接依赖 PB2dAgentPathBerserker2d 具体类型),一旦切换导航中间件需改全部 BD Task。通过接口隔离实现导航层可替换。

// 路径: Assets/Scripts/Enemies/Navigation/IPathAgent.cs
namespace BaseGames.Enemy
{
    /// <summary>
    /// 导航代理抽象接口。BD Task 和 EnemyBase 只依赖此接口,不依赖具体导航库。
    /// 实现EnemyNavAgentPathBerserker2d测试用 NullPathAgent无移动
    /// </summary>
    public interface IPathAgent
    {
        /// <summary>请求移动到世界坐标 <paramref name="target"/>。</summary>
        void RequestMoveTo(Vector2 target);

        /// <summary>立即停止导航(清除路径)。</summary>
        void StopNavigation();

        /// <summary>是否已到达目标(距离 ≤ stoppingDistance。</summary>
        bool IsAtDestination();

        /// <summary>运行时覆盖移动速度。</summary>
        void SetSpeed(float speed);

        /// <summary>当前帧是否在移动(速度 > 0.01 且有有效路径)。</summary>
        bool IsMoving { get; }

        /// <summary>路径寻路失败事件(目标不可达时触发)。</summary>
        event System.Action OnNavPathFailed;
    }

    // ── 测试 / 无导航敌人用 ────────────────────────────────────────────
    public sealed class NullPathAgent : IPathAgent
    {
        public void RequestMoveTo(Vector2 _) { }
        public void StopNavigation()         { }
        public bool IsAtDestination()        => true;
        public void SetSpeed(float _)        { }
        public bool IsMoving                 => false;
        public event System.Action OnNavPathFailed { add { } remove { } }
    }
}

5.1 EnemyNavAgent

// 路径: Assets/Scripts/Enemies/Navigation/EnemyNavAgent.cs
// 封装 PathBerserker2d 的 PB2dAgent 组件,实现 IPathAgent
[RequireComponent(typeof(PB2dAgent))]
public class EnemyNavAgent : MonoBehaviour, IPathAgent
{
    private PB2dAgent _agent;

    public bool IsMoving   { get; private set; }

    // ── IPathAgent 实现 ───────────────────────────────────────────────
    public void RequestMoveTo(Vector2 target);          // 设置 PathBerserker2d 目标
    public void StopNavigation();                       // 停止导航
    public bool IsAtDestination();                      // 是否已到达目标位置
    public void SetSpeed(float speed);                  // 运行时覆盖 PB2dAgent.speed

    public event System.Action OnNavPathFailed;         // 路径寻路失败

    // 边缘/跌落检测(用于 BD Conditional
    public bool IsNearEdge(float checkDist = 0.5f);
}

BD Task 调用示例BD 中 MoveToTarget 任务声明 [RequireComponent] IPathAgent _nav(通过 GetComponent<IPathAgent>() 获取),不直接引用 EnemyNavAgentPB2dAgent


6. EnemyStatsSO

[CreateAssetMenu(menuName = "Enemies/EnemyStats")]
public class EnemyStatsSO : ScriptableObject
{
    [Header("Identity")]
    public string  DisplayName;
    public string  EnemyId;       // 全局唯一 ID存档用

    [Header("HP & Defense")]
    public int     MaxHP;
    public int     DefenseStat;

    [Header("Movement")]
    public float   WalkSpeed     = 3f;
    public float   RunSpeed      = 5f;
    public float   PatrolDistance = 5f;

    [Header("Detection")]
    public float   DetectionRange  = 8f;
    public float   LoseAggroRange  = 12f;
    public LayerMask SightBlockMask;

    [Header("Combat")]
    public float   AttackRange    = 1.5f;
    public float   AttackCooldown = 1.5f;
    [Range(0f, 1f)]
    public float   StaggerResistance = 0f;   // 0=正常受硬直1=完全免疫 Stagger精英怪用

    [Header("Drops")]
    public int     GeoDropAmount  = 3;
    public int     GeoDropVariance = 2;
    public int     KillPoints     = 1;   // 贡献给 PlayerStats.SpringKillPoints

    // ⚠️ MaxPoise / PoiseRegenDelay 已移至 PoiseSystem 组件EnemyStatsSO 不再重复定义
}

7. EnemyAnimationConfigSO

[CreateAssetMenu(menuName = "Enemies/AnimationConfig")]
public class EnemyAnimationConfigSO : ScriptableObject
{
    public AnimationClip Idle;
    public AnimationClip Walk;
    public AnimationClip Run;
    public AnimationClip Alert;              // 警觉动画(发现玩家时短暂播放)
    public AnimationClip Attack_Melee;       // 近战攻击动画
    public AnimationClip Attack_Ranged;      // 远程攻击动画(无则留空)
    public AnimationClip[] AttackVariants;   // 多段/多种攻击变体
    public AnimationClip Hurt;
    public AnimationClip Stagger;
    public AnimationClip Dead;
}

8. Behavior Designer 任务类目录

所有 BD 任务位于 Assets/Scripts/Enemies/AI/,命名格式:BD_{功能}.cs

Action 任务

类名 功能 关键 SharedVariable
BD_MoveTo 移动到 Target 位置 SharedVector2 Target
BD_MoveToPlayer 持续追踪玩家(调用 RequestMoveTo + 实时更新) SharedTransform playerTransform
BD_Patrol 在出生点±PatrolDistance 间往返 SharedFloat PatrolRange
BD_Wait 等待固定秒数 SharedFloat Duration
BD_WaitRandom 等待随机秒数(最小/最大范围) SharedFloat Min, SharedFloat Max
BD_SetAlert 设置警觉状态并播放 Alert 动画 SharedBool isAlerted
BD_FaceTarget 朝向玩家
BD_StopMovement 停止移动
BD_Attack 触发攻击(调用 EnemyBase.BeginAttack SharedInt AttackType
BD_WaitForAnimation 等待当前动画播放完毕
BD_PlayAnimation 播放指定 Animancer 动画 SharedString ClipName
BD_TelegraphAttack 发出攻击预警(见 TelegraphSystem SharedFloat Duration
BD_SpawnProjectile 生成弹射物 SharedVector2 Direction, SharedString ProjectileKey
BD_JumpTo 跳跃至目标 SharedVector2 Target
BD_TeleportTo 瞬移到目标坐标Boss 专用) SharedVector2 Target
BD_SummonMinions Boss 召唤小兵 SharedString MinionPrefabKey
BD_EnterPhase Boss 切换阶段 SharedInt PhaseIndex

Conditional 任务

类名 功能
BD_IsPlayerInRange 玩家在攻击范围内
BD_IsPlayerVisible 玩家可见LOS 检测)
BD_CanAttack 攻击冷却完毕
BD_IsHPBelow HP 低于阈值Boss 换相)
BD_IsGrounded 处于地面
BD_IsNearEdge 靠近平台边缘
BD_IsStateMatch 当前 EnemyStateType 匹配

BD SharedVariables行为树全局变量

所有 EnemyBase 挂载的 BehaviorTree 共用以下 SharedVariablesEnemyBase.Awake() 通过 BehaviorTree.GetVariable() 注入:

变量名 类型 说明
playerTransform SharedTransform 玩家 Transform 引用,由 EnemyBase 在每帧更新
selfTransform SharedTransform 敌人自身 Transform初始化时注入
patrolPointA SharedVector2 巡逻左端点EnemyBase 根据出生位置计算)
patrolPointB SharedVector2 巡逻右端点
isAlerted SharedBool 是否处于警觉状态(BD_SetAlert 写入)
targetPosition SharedVector2 当前导航目标(各 Action 任务写入)
hpThreshold SharedFloat 当前 HP 比例(每帧由 EnemyBase 更新,供 BD_IsHPBelow 读取)

9. BossBase

// 路径: Assets/Scripts/Enemies/Boss/BossBase.cs
// 继承 EnemyBase添加 Boss 专用逻辑
public class BossBase : EnemyBase
{
    [SerializeField] protected BossOrchestrator     _orchestrator;
    [SerializeField] protected TelegraphSystem      _telegraph;

    [Header("Boss 识别")]
    [SerializeField] private string                 _bossId;          // Boss 资产唯一 ID

    [Header("Event Channels")]
    [SerializeField] private BoolEventChannelSO     _onBossFightEnded;    // 发布胜负
    [SerializeField] private BossPhaseEventChannelSO _onBossPhaseChanged; // 发布阶段切换

    public string BossId => _bossId;
    protected int _currentPhase = 0;

    // Boss 专属接口(由 BD_EnterPhase 任务调用)
    public virtual void EnterPhase(int phase)
    {
        _currentPhase = phase;
        // 1. 短暂无敌帧
        // 2. 播放 Phase 过渡演出动画
        _onBossPhaseChanged.Raise(new BossPhaseEvent { BossId = _bossId, Phase = phase });
    }

    public override void Die()
    {
        base.Die();
        _onBossFightEnded.Raise(true);    // 广播胜利
        // 触发死亡过场EventChain
    }

    // HP 阈值检查BD_IsHPBelow 使用)
    public bool IsHPBelow(float ratio) => (float)_stats.CurrentHP / _stats.MaxHP < ratio;
}

// ─── BossPhaseConfigSO ──────────────────────────────────────────────────────
// 每个 Boss 的各阶段独立配置(挂载在 BossBase 的 phaseConfigs[] 数组中)
[CreateAssetMenu(menuName = "Enemies/Boss/PhaseConfig")]
public class BossPhaseConfigSO : ScriptableObject
{
    public int                  PhaseIndex;             // 阶段编号(从 0 开始)
    [Range(0f, 1f)]
    public float                HPThreshold;            // HP 低于此比例时切换0 = 从不自动切换)
    public ExternalBehaviorTree BehaviorTreeAsset;      // 该阶段使用的行为树资产
    public AudioClip            MusicTrack;             // 该阶段 BGM可为 null
    public MMF_Player           PhaseTransitionFeedback; // 切换阶段时播放的 Feel 特效
}

10. BossOrchestrator

// 路径: Assets/Scripts/Enemies/Boss/Patterns/BossOrchestrator.cs
// 协调 Boss 阶段、技能执行、进场/退场表演
public class BossOrchestrator : MonoBehaviour
{
    [SerializeField] private BossBase             _boss;
    [SerializeField] private TelegraphSystem      _telegraph;
    [SerializeField] private BoxCollider2D        _arenaBlocker;       // 进入 Boss 房间后关闭出口

    [Header("Boss 技能Phase 4 BossSkillModule 添加)")]
    [SerializeField] private BossSkillSO[]        _phaseOneSkills;     // 一阶技能列表
    [SerializeField] private BossSkillSO[]        _phaseTwoSkills;     // 二阶技能列表
    [SerializeField] private BossSkillExecutor    _executor;

    [Header("Event Channels")]
    [SerializeField] private StringEventChannelSO _onBossFightStarted; // 发布战斗开始

    int _currentPhase = 1;
    CancellationTokenSource _cts;

    // 玩家进入触发器时调用
    public void StartBossFight()
    {
        _arenaBlocker.enabled = true;
        _onBossFightStarted.Raise(_boss.BossId);
        _boss.GetComponent<BehaviorTree>().EnableBehavior();
    }

    // BD Task 节点调用ExecuteSkillById(skillId)
    public async UniTask ExecuteSkillById(string skillId)
    {
        var skills = _currentPhase == 1 ? _phaseOneSkills : _phaseTwoSkills;
        var skill  = System.Array.Find(skills, s => s.skillId == skillId);
        if (skill == null) return;
        _cts?.Cancel();
        _cts = new CancellationTokenSource();
        await _executor.ExecuteSkill(skill, _cts.Token);
    }

    public void EnterPhaseTwo()
    {
        _currentPhase = 2;
        _cts?.Cancel(); // 打断当前技能
        // 阶段切换由 BossBase.EnterPhase(2) 调用并广播 EVT_BossPhaseChanged
    }
}

11. AttackPatternSO 与 TelegraphSystem

// 路径: Assets/Scripts/Enemies/Boss/Patterns/AttackPatternSO.cs
[CreateAssetMenu(menuName = "Enemies/Boss/AttackPattern")]
public class AttackPatternSO : ScriptableObject
{
    [System.Serializable]
    public struct AttackEntry
    {
        public string    AttackId;          // BD 任务通过此 ID 触发
        public float     Weight;            // 随机权重(模式随机选择)
        public float     Cooldown;
        public float     TelegraphDuration; // 0 = 无预警
        public string    TelegraphVfxKey;   // Addressable 键
        public DamageSourceSO DamageSource;
    }

    public AttackEntry[] Attacks;
    public float         PhaseSpeedMultiplier = 1f;  // 此阶段移速倍率
}

// 路径: Assets/Scripts/Enemies/Boss/Patterns/TelegraphSystem.cs
// 攻击预警系统:在攻击前若干帧显示视觉提示
public class TelegraphSystem : MonoBehaviour
{
    [SerializeField] private ObjectPoolManager _pool;

    // 开始预警BD_TelegraphAttack 调用)
    public IEnumerator ShowTelegraph(string vfxKey, float duration, Vector2 position);
    // 1. 从对象池取出预警 VFX
    // 2. 等待 duration 秒
    // 3. 归还 VFX攻击开始
}

11.1 AttackPatternEditor命中帧可视化 Inspector

路径Assets/Editor/Enemies/AttackPatternEditor.cs
目标用户Boss 战斗设计师(配置攻击权重、预警时长、伤害数值时,无需对照原始 Inspector 多层嵌套)

[CustomEditor(typeof(AttackPatternSO))]
public class AttackPatternEditor : UnityEditor.Editor
{
    // ── 折叠状态 ─────────────────────────────────────────────────────
    private bool[] _foldouts;

    public override void OnInspectorGUI()
    {
        var so   = (AttackPatternSO)target;
        var prop = serializedObject;
        prop.Update();

        // ── 整体参数 ─────────────────────────────────────────────────
        EditorGUILayout.PropertyField(prop.FindProperty("PhaseSpeedMultiplier"));
        EditorGUILayout.Space(6);

        // ── 攻击条目列表(可视化条形图 + 折叠面板) ─────────────────
        EditorGUILayout.LabelField("AttackEntries", EditorStyles.boldLabel);
        var attacks = so.Attacks;
        if (attacks == null || attacks.Length == 0)
        {
            EditorGUILayout.HelpBox("尚无攻击条目。", MessageType.Info);
        }
        else
        {
            if (_foldouts == null || _foldouts.Length != attacks.Length)
                _foldouts = new bool[attacks.Length];

            float totalWeight = 0f;
            foreach (var a in attacks) totalWeight += Mathf.Max(a.Weight, 0.001f);

            for (int i = 0; i < attacks.Length; i++)
            {
                var entry = attacks[i];
                float ratio = entry.Weight / totalWeight;

                // 折叠标题行:「[Bar████░░] AttackId   权重%」
                EditorGUILayout.BeginHorizontal();
                var barRect = GUILayoutUtility.GetRect(0, 16, GUILayout.Width(80));
                EditorGUI.DrawRect(barRect, new Color(0.2f, 0.4f, 0.8f, 0.3f));
                EditorGUI.DrawRect(new Rect(barRect.x, barRect.y, barRect.width * ratio, barRect.height),
                                   new Color(0.2f, 0.6f, 1f, 0.8f));

                _foldouts[i] = EditorGUILayout.Foldout(_foldouts[i],
                    $"  {entry.AttackId}  ({ratio:P0})", true);
                EditorGUILayout.EndHorizontal();

                if (_foldouts[i])
                {
                    EditorGUI.indentLevel++;
                    var arrayProp = prop.FindProperty("Attacks").GetArrayElementAtIndex(i);
                    EditorGUILayout.PropertyField(arrayProp.FindPropertyRelative("AttackId"));
                    EditorGUILayout.PropertyField(arrayProp.FindPropertyRelative("Weight"));
                    EditorGUILayout.PropertyField(arrayProp.FindPropertyRelative("Cooldown"));

                    // 预警时长0 = 无预警(灰色提示)
                    float tele = entry.TelegraphDuration;
                    EditorGUILayout.PropertyField(arrayProp.FindPropertyRelative("TelegraphDuration"));
                    if (tele <= 0f)
                        EditorGUILayout.HelpBox("TelegraphDuration = 0此攻击无预警即时", MessageType.None);

                    EditorGUILayout.PropertyField(arrayProp.FindPropertyRelative("TelegraphVfxKey"));
                    EditorGUILayout.PropertyField(arrayProp.FindPropertyRelative("DamageSource"));

                    // 快速展示 DamageSource 摘要(避免策划必须展开嵌套 SO
                    if (entry.DamageSource != null)
                    {
                        EditorGUI.indentLevel++;
                        EditorGUILayout.LabelField(
                            $"伤害预览: {entry.DamageSource.BaseDamage} × {entry.DamageSource.DamageMultiplier:F1} " +
                            $"= {Mathf.RoundToInt(entry.DamageSource.BaseDamage * entry.DamageSource.DamageMultiplier)} [{entry.DamageSource.Type}]",
                            EditorStyles.miniLabel);
                        EditorGUI.indentLevel--;
                    }

                    // 一键 Ping 关联 SO
                    if (entry.DamageSource != null && GUILayout.Button("Ping DamageSourceSO", GUILayout.Height(18)))
                        EditorGUIUtility.PingObject(entry.DamageSource);

                    EditorGUI.indentLevel--;
                }
            }
        }

        prop.ApplyModifiedProperties();
    }
}

提供能力一览

功能 收益
随机权重百分比 + 条形图 策划直观感受攻击频率分布
TelegraphDuration=0 警告 防止无意做成即时攻击
DamageSource 内联摘要 查看伤害值无需展开嵌套 SO
Ping 关联 SO 按钮 快速定位引用资产

12. DeathShade死亡遗骸

// 路径: Assets/Scripts/World/DeathShade.cs位于 World 命名空间)
// 玩家死亡时在最后位置生成,携带死亡时的 Geo与之交互可回收 Geo
public class DeathShade : MonoBehaviour, IInteractable
{
    [SerializeField] private StringEventChannelSO _onGeoRecovered;

    private int     _storedGeo;
    private string  _sceneId;
    private Vector2 _worldPosition;

    public void Initialize(int geo, string sceneId, Vector2 pos);

    public void Interact(Transform player)
    {
        // ⚠️ IInteractable 规范:参数为 Transform需要 PlayerStats 时通过 player.GetComponent<PlayerController>() 获取PlayerController 无 Instance
        var pc = player.GetComponent<PlayerController>();
        if (pc != null) pc.Stats.AddGeo(_storedGeo);
        _onGeoRecovered.Raise(_sceneId);
        // 归还对象池 or 销毁
    }

    // 存档集成(世界状态)
    public DeathShadeSaveData GetSaveData();
    public void LoadSaveData(DeathShadeSaveData data);
}

13. Enemy Prefab 层级结构

Assets/Prefabs/Enemies/ENM_{EnemyName}.prefab

[ENM_GruntWarrior]              ← EnemyBase + AnimancerComponent
├── EnemyStats
├── EnemyMovement               ← Rigidbody2D (Dynamic)
├── EnemyCombat
├── EnemyNavAgent               ← PB2dAgent
├── EnemyAnimator               ← 管理 Animancer 状态机调用(监听 EnemyStateType 变化)
├── EnemyFeedback               ← MMF_Player
├── StatusEffectManager
├── PoiseSystem
├── BehaviorTree                ← Behavior Designer
├── [Body]                      ← SpriteRenderer
├── [DetectionRange]            ← CircleCollider2D Trigger, Layer: Default视野检测
│   └── EnemyDetection.cs检测玩家进入/离开,设置 SharedBool isAlerted
├── [HurtBox]                   ← BoxCollider2D Trigger, Layer: EnemyHurtBox
│   └── HurtBox.cs
└── [HitBox_Melee]              ← BoxCollider2D Trigger, Layer: EnemyAttack
    └── HitBox.cs

13.5 Behavior Designer 性能规范

P1 优化BD 行为树无约束时容易演变为超深树(>10 层)和全帧 Tick 模式,导致 CPU 超预算。以下规范为强制规范CodeReview 时核查。

行为树结构约束

约束项 限制值 原因
树最大深度 5 层 超深树调试困难用子树SubtreeBehavior平拆
单棵树 Task 总数 30 个 超过时拆为主树 + 子树
SharedVariable 总数/敌人 20 个 超过说明数据粒度过细,应合并
嵌套 SubtreeBehavior 层数 2 层 防止子树递归深度失控

Tick 频率规范

// 路径: Assets/Scripts/Enemies/Base/EnemyBase.csBehaviorTree 初始化段)
void InitBehaviorTree()
{
    _behaviorTree = GetComponent<BehaviorTree>();
    _behaviorTree.StartWhenEnabled = false;
    _behaviorTree.RestartWhenComplete = true;

    // 非警觉状态:每 2 帧 Tick 一次(巡逻/Idle 敌人)
    // 警觉状态:恢复每帧 Tick
    _behaviorTree.UpdateInterval = UpdateIntervalType.EveryNFrames;
    _behaviorTree.UpdateIntervalFrame = NON_AGGRO_TICK_INTERVAL;

    _behaviorTree.EnableBehavior();
}

/// <summary>
/// 警觉状态变更时调用(由 DetectionRange 触发)。
/// 非警觉:每 2 帧 Tick警觉每帧 Tick。
/// </summary>
public void SetAggroTickRate(bool isAggro)
{
    _behaviorTree.UpdateIntervalFrame = isAggro ? 1 : NON_AGGRO_TICK_INTERVAL;
}

private const int NON_AGGRO_TICK_INTERVAL = 2;

同屏敌人数量限制

// 路径: Assets/Scripts/Enemies/EnemyQuotaManager.cs
namespace BaseGames.Enemy
{
    /// <summary>
    /// 管控同时激活的 BehaviorTree 敌人数量,超出配额时暂停最远敌人 BT。
    /// 防止大量敌人在屏幕外全速 Tick 消耗 CPU。
    /// </summary>
    public class EnemyQuotaManager : MonoBehaviour
    {
        [SerializeField, Min(1)] int _maxActiveBehaviorTrees = 12;

        // 每 10 帧重新评估(避免每帧排序)
        private const int REBALANCE_INTERVAL = 10;
        private int _frameCount;
        private readonly List<EnemyBase> _registered = new();

        public void Register(EnemyBase enemy)   => _registered.Add(enemy);
        public void Unregister(EnemyBase enemy) => _registered.Remove(enemy);

        void Update()
        {
            if (++_frameCount % REBALANCE_INTERVAL != 0) return;
            Rebalance();
        }

        private void Rebalance()
        {
            var playerPos = PlayerController.Instance != null
                ? PlayerController.Instance.transform.position
                : Vector3.zero;

            // 按距离排序,近的优先激活
            _registered.Sort((a, b) =>
                Vector3.SqrMagnitude(a.transform.position - playerPos)
                .CompareTo(Vector3.SqrMagnitude(b.transform.position - playerPos)));

            for (int i = 0; i < _registered.Count; i++)
            {
                bool active = i < _maxActiveBehaviorTrees;
                var bt = _registered[i].BehaviorTree;
                if (bt.enabled != active) bt.enabled = active;
            }
        }
    }
}

Profiler 标记规范

所有 BD Task 的 OnUpdate() 应包裹 Profiler Marker格式BD.TaskName

// BD Task 示例
public class MoveToPlayer : Action
{
    private static readonly ProfilerMarker k_Marker =
        new(ProfilerCategory.AI, "BD.MoveToPlayer");

    public override TaskStatus OnUpdate()
    {
        using var _ = k_Marker.Auto();
        // ... 导航逻辑
        return TaskStatus.Running;
    }
}

性能检查清单Code Review

  • 行为树深度 ≤ 5 层Inspector 或 BD Editor 目测)
  • 非警觉敌人 UpdateIntervalFrame ≥ 2
  • 同场景敌人受 EnemyQuotaManager 管控
  • 没有在 OnUpdate() 中调用 FindObjectsOfTypeGetComponentsInChildren 等高开销 API
  • LOS 实现路径≤4 活跃敌人时允许使用 3 帧节流降级回退;>4 活跃敌人时必须实现 ILOSRequester 并注册到 BatchLOSSystem(见 §15
  • 所有 BD Task 包含 ProfilerMarker

14. LootTableSO 与 LootResolver

// 路径: Assets/Scripts/Enemies/Loot/LootTableSO.cs
// 战利品掉落配置(概率 + 难度系数)
[CreateAssetMenu(menuName = "Enemies/LootTable")]
public class LootTableSO : ScriptableObject
{
    [System.Serializable]
    public struct LootEntry
    {
        public string   ItemId;         // "" 表示 Geo 掉落
        public int      GeoAmount;      // ItemId 为空时使用
        public float    BaseWeight;     // 基础权重
        public bool     ScaleWithDifficulty;  // true → Hard 难度权重 × 1.5
    }

    public LootEntry[] Entries;
    public int         GuaranteedGeoMin;   // 死亡时必掉的最低 Geo
    public int         GuaranteedGeoMax;
}

// 路径: Assets/Scripts/Enemies/Loot/LootResolver.cs
// static 工具类,按概率掷骰,调用 Collectible 系统生成掉落物
public static class LootResolver
{
    /// <summary>
    /// 根据 LootTableSO 在 worldPosition 处生成战利品
    /// </summary>
    public static void Resolve(LootTableSO table, Vector2 worldPosition)
    {
        if (table == null) return;

        // 1. 掉落保底 Geo
        int geo = Random.Range(table.GuaranteedGeoMin, table.GuaranteedGeoMax + 1);
        if (geo > 0)
            CollectibleSpawner.SpawnGeo(worldPosition, geo);

        // 2. 按权重随机掉落条目
        float totalWeight = 0f;
        var difficulty = DifficultyManager.Instance.CurrentLevel;
        foreach (var entry in table.Entries)
        {
            float w = entry.BaseWeight;
            if (entry.ScaleWithDifficulty && difficulty >= DifficultyLevel.Hard) w *= 1.5f;
            totalWeight += w;
        }

        float roll = Random.Range(0f, totalWeight);
        float cumulative = 0f;
        foreach (var entry in table.Entries)
        {
            float w = entry.BaseWeight;
            if (entry.ScaleWithDifficulty && difficulty >= DifficultyLevel.Hard) w *= 1.5f;
            cumulative += w;
            if (roll <= cumulative)
            {
                if (!string.IsNullOrEmpty(entry.ItemId))
                    CollectibleSpawner.SpawnItem(worldPosition, entry.ItemId);
                break;
            }
        }
    }
}

15. BatchLOSSystem — Burst/Jobs 批量视线检测

P1 优化:场景中同时存在 820 个活跃敌人时,每个 EnemyBase.CheckLineOfSight() 各自
调用一次 Physics2D.Raycast,即使添加了 3 帧节流§13.5),单帧仍可有 67 次
主线程 Raycast 调用。
引入 BatchLOSSystem,把全部视线检测合并为一次 RaycastCommand 批处理作业,
IJobParallelFor + Burst 编译器在工作线程执行,主线程无阻塞。

15.1 设计原则

BatchLOSSystem 职责:
  ├─ 每帧(固定 FixedUpdate收集所有 ILOSRequester 的查询请求
  ├─ 批量调用 Physics2D.RaycastCommand → JobHandleJobs + Burst
  ├─ 下一帧回调各 ILOSRequester 写入结果,敌人读取缓存值
  └─ EnemyBase 的 CheckLineOfSight() 改为读缓存,不再直接 Raycast

零改动原则EnemyBase.IsPlayerVisible() 外部签名不变;
内部由 "直接 Raycast" 改为 "读 _losResult 字段BatchLOSSystem 每帧写入)"。

15.2 ILOSRequester 接口

// 路径: Assets/Scripts/Enemies/AI/ILOSRequester.cs
namespace BaseGames.Enemies.AI
{
    /// <summary>
    /// 需要视线检测的组件实现此接口,由 BatchLOSSystem 统一管理。
    /// </summary>
    public interface ILOSRequester
    {
        /// <summary>射线起点(通常为敌人眼睛位置)。</summary>
        Vector2 LOSOrigin { get; }

        /// <summary>射线终点(通常为玩家质心)。</summary>
        Vector2 LOSTarget { get; }

        /// <summary>遮挡层遮罩(只检测地形层)。</summary>
        LayerMask LOSBlockingMask { get; }

        /// <summary>
        /// 由 BatchLOSSystem 在作业完成后回调,写入本帧视线检测结果。
        /// </summary>
        void ReceiveLOSResult(bool hasLineOfSight);
    }
}

15.3 BatchLOSSystem 核心

// 路径: Assets/Scripts/Enemies/AI/BatchLOSSystem.cs
using Unity.Collections;
using Unity.Jobs;
using UnityEngine;

namespace BaseGames.Enemies.AI
{
    /// <summary>
    /// 全局批量视线检测系统。Persistent 场景常驻,或由 EnemyManager 初始化。
    /// </summary>
    public class BatchLOSSystem : MonoBehaviour
    {
        // 同时注册的最大请求方(场景内活跃敌人 + Boss 通常 ≤ 24
        private const int MAX_REQUESTERS = 32;

        private readonly List<ILOSRequester> _requesters = new(MAX_REQUESTERS);
        private NativeArray<RaycastCommand>  _commands;
        private NativeArray<RaycastHit2D>    _results;
        private JobHandle                    _jobHandle;
        private bool                         _jobScheduled;

        // ── 注册 / 注销EnemyBase.Awake / OnDestroy 调用)─────────────────
        public void Register(ILOSRequester requester)   => _requesters.Add(requester);
        public void Unregister(ILOSRequester requester) => _requesters.Remove(requester);

        private void Awake()
        {
            _commands = new NativeArray<RaycastCommand>(MAX_REQUESTERS, Allocator.Persistent);
            _results  = new NativeArray<RaycastHit2D>(MAX_REQUESTERS, Allocator.Persistent);
        }

        private void OnDestroy()
        {
            if (_jobScheduled) _jobHandle.Complete();
            _commands.Dispose();
            _results.Dispose();
        }

        // ── FixedUpdate收集请求 → 调度作业 ────────────────────────────────
        private void FixedUpdate()
        {
            // 1. 完成上一帧作业,读取结果
            if (_jobScheduled)
            {
                _jobHandle.Complete();
                _jobScheduled = false;
                for (int i = 0; i < _requesters.Count; i++)
                {
                    bool hit = _results[i].collider != null;
                    // hit = 射线被地形拦截 → 无视线;未命中 → 有视线
                    _requesters[i].ReceiveLOSResult(!hit);
                }
            }

            // 2. 构建本帧 RaycastCommand 批次
            int count = _requesters.Count;
            for (int i = 0; i < count; i++)
            {
                var req = _requesters[i];
                Vector2 dir    = req.LOSTarget - req.LOSOrigin;
                float   dist   = dir.magnitude;
                // QueryParameters 指定层遮罩hitMultipleFaces=false 只取第一个碰撞体
                _commands[i] = new RaycastCommand(
                    from:  req.LOSOrigin,
                    direction: dir.normalized,
                    queryParameters: new QueryParameters(req.LOSBlockingMask, false, false, false),
                    distance: dist
                );
            }

            // 3. 调度并行 Raycast 作业Burst 编译,工作线程执行)
            if (count > 0)
            {
                _jobHandle    = RaycastCommand.ScheduleBatch(_commands.GetSubArray(0, count),
                                    _results.GetSubArray(0, count),
                                    minCommandsPerJob: 4);   // 每 Job 处理 4 条射线
                _jobScheduled = true;
            }
        }
    }
}

15.4 EnemyBase 适配

// EnemyBase.cs — 改动点(最小侵入)
// 新增字段
private bool _losResult;      // BatchLOSSystem 每帧写入

// 实现 ILOSRequester新增接口
public Vector2    LOSOrigin      => (Vector2)transform.position + _statsSO.EyeOffset;
public Vector2    LOSTarget      => _playerTransform.position;
public LayerMask  LOSBlockingMask => _statsSO.LOSBlockingMask;
public void ReceiveLOSResult(bool hasLOS) => _losResult = hasLOS;

// CheckLineOfSight() 改为读缓存(不再直接 Raycast
private bool CheckLineOfSight() => _losResult;

// Awake 中注册
private void Awake()
{
    _behaviorTree = GetComponent<BehaviorTree>();
    _batchLOS     = ServiceLocator.Get<BatchLOSSystem>();  // 或通过 FindObjectOfType 降级
    _batchLOS?.Register(this);
}

private void OnDestroy() => _batchLOS?.Unregister(this);

15.5 性能对比

场景 原方案(逐帧单条 Raycast 新方案BatchLOSSystem
12 活跃敌人3 帧节流 主线程 4 次/帧 Raycast2D 主线程 0 次,工作线程 Burst 批处理
20 活跃敌人,无节流 主线程 20 次/帧 主线程 0 次,单次 JobHandle 调度
GC NativeArray Persistent
结果延迟 即时 1 帧延迟FixedUpdate → 下一 FixedUpdate 回调)

1 帧延迟可接受LOS 结果用于 BD 决策树Aggro/非 Aggro 切换1 帧延迟(~16ms
在游戏感知层面不可察觉。如需零延迟(如弹幕激活),继续使用 Physics2D.Raycast

15.6 EnemyStatsSO 新增字段

// EnemyStatsSO 新增(见 §6
[Header("视线检测")]
public Vector2   EyeOffset      = new Vector2(0f, 0.5f);   // 眼睛相对 Transform 偏移
public LayerMask LOSBlockingMask;                           // Inspector 配置:勾选 Ground + Wall