# 07 · 敌人模块 > **命名空间** `BaseGames.Enemies`、`BaseGames.Enemies.AI`、`BaseGames.Enemies.Navigation`、`BaseGames.Enemies.Boss.Patterns` > **程序集** 见各 asmdef > **路径** `Assets/Scripts/Enemies/` > **依赖** `BaseGames.Combat`、`Kybernetik.Animancer`、`BehaviorDesigner.Runtime`、`PathBerserker2d` --- ## 目录 1. [EnemyBase](#1-enemybase) 2. [EnemyStats](#2-enemystats) 3. [EnemyMovement](#3-enemymovement) 4. [EnemyCombat](#4-enemycombat) 5. [EnemyNavAgent](#5-enemynavagent) 6. [EnemyStatsSO](#6-enemystatsso) 7. [EnemyAnimationConfigSO](#7-enemyanimationconfigso) 8. [Behavior Designer 任务类目录](#8-behavior-designer-任务类目录) 9. [BossBase](#9-bossbase) 10. [BossOrchestrator](#10-bossorchestrator) 11. [AttackPatternSO 与 TelegraphSystem](#11-attackpatternso-与-telegraphsystem) 12. [DeathShade(死亡遗骸)](#12-deathshade) 13. [Enemy Prefab 层级结构](#13-enemy-prefab-层级结构) 14. [LootTableSO 与 LootResolver](#14-loottableso-与-lootresolver) 15. [BatchLOSSystem — Burst/Jobs 批量视线检测](#15-batchlossystem--burstjobs-批量视线检测) --- ## 1. EnemyBase ```csharp // 路径: 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 Components(Inspector 引用,同 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() 缓存,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 读写 SharedVariable;Awake 缓存、消除 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. 掉落 Geo(Collectible 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 ```csharp // 路径: 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 ```csharp // 路径: 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(追击时用 RunSpeed,BD 任务设置 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 ```csharp // 路径: 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` 直接依赖 `PB2dAgent`(PathBerserker2d 具体类型),一旦切换导航中间件需改全部 BD Task。通过接口隔离实现导航层可替换。 ```csharp // 路径: Assets/Scripts/Enemies/Navigation/IPathAgent.cs namespace BaseGames.Enemy { /// /// 导航代理抽象接口。BD Task 和 EnemyBase 只依赖此接口,不依赖具体导航库。 /// 实现:EnemyNavAgent(PathBerserker2d);测试用 NullPathAgent(无移动)。 /// public interface IPathAgent { /// 请求移动到世界坐标 void RequestMoveTo(Vector2 target); /// 立即停止导航(清除路径)。 void StopNavigation(); /// 是否已到达目标(距离 ≤ stoppingDistance)。 bool IsAtDestination(); /// 运行时覆盖移动速度。 void SetSpeed(float speed); /// 当前帧是否在移动(速度 > 0.01 且有有效路径)。 bool IsMoving { get; } /// 路径寻路失败事件(目标不可达时触发)。 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 ```csharp // 路径: 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()` 获取),不直接引用 `EnemyNavAgent` 或 `PB2dAgent`。 --- ## 6. EnemyStatsSO ```csharp [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 ```csharp [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` 共用以下 SharedVariables,由 `EnemyBase.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 ```csharp // 路径: 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 ```csharp // 路径: 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().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 ```csharp // 路径: 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 多层嵌套) ```csharp [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(死亡遗骸) ```csharp // 路径: 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 无 Instance) var pc = player.GetComponent(); 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 频率规范 ```csharp // 路径: Assets/Scripts/Enemies/Base/EnemyBase.cs(BehaviorTree 初始化段) void InitBehaviorTree() { _behaviorTree = GetComponent(); _behaviorTree.StartWhenEnabled = false; _behaviorTree.RestartWhenComplete = true; // 非警觉状态:每 2 帧 Tick 一次(巡逻/Idle 敌人) // 警觉状态:恢复每帧 Tick _behaviorTree.UpdateInterval = UpdateIntervalType.EveryNFrames; _behaviorTree.UpdateIntervalFrame = NON_AGGRO_TICK_INTERVAL; _behaviorTree.EnableBehavior(); } /// /// 警觉状态变更时调用(由 DetectionRange 触发)。 /// 非警觉:每 2 帧 Tick;警觉:每帧 Tick。 /// public void SetAggroTickRate(bool isAggro) { _behaviorTree.UpdateIntervalFrame = isAggro ? 1 : NON_AGGRO_TICK_INTERVAL; } private const int NON_AGGRO_TICK_INTERVAL = 2; ``` ### 同屏敌人数量限制 ```csharp // 路径: Assets/Scripts/Enemies/EnemyQuotaManager.cs namespace BaseGames.Enemy { /// /// 管控同时激活的 BehaviorTree 敌人数量,超出配额时暂停最远敌人 BT。 /// 防止大量敌人在屏幕外全速 Tick 消耗 CPU。 /// public class EnemyQuotaManager : MonoBehaviour { [SerializeField, Min(1)] int _maxActiveBehaviorTrees = 12; // 每 10 帧重新评估(避免每帧排序) private const int REBALANCE_INTERVAL = 10; private int _frameCount; private readonly List _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`: ```csharp // 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()` 中调用 `FindObjectsOfType`、`GetComponentsInChildren` 等高开销 API - [ ] LOS 实现路径:≤4 活跃敌人时允许使用 3 帧节流降级回退;>4 活跃敌人时**必须**实现 `ILOSRequester` 并注册到 `BatchLOSSystem`(见 §15) - [ ] 所有 BD Task 包含 `ProfilerMarker` --- ## 14. LootTableSO 与 LootResolver ```csharp // 路径: 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 { /// /// 根据 LootTableSO 在 worldPosition 处生成战利品 /// 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 优化**:场景中同时存在 8–20 个活跃敌人时,每个 `EnemyBase.CheckLineOfSight()` 各自 > 调用一次 `Physics2D.Raycast`,即使添加了 3 帧节流(§13.5),单帧仍可有 6–7 次 > 主线程 Raycast 调用。 > 引入 `BatchLOSSystem`,把全部视线检测合并为一次 `RaycastCommand` 批处理作业, > 由 `IJobParallelFor` + Burst 编译器在工作线程执行,主线程无阻塞。 ### 15.1 设计原则 ``` BatchLOSSystem 职责: ├─ 每帧(固定 FixedUpdate)收集所有 ILOSRequester 的查询请求 ├─ 批量调用 Physics2D.RaycastCommand → JobHandle(Jobs + Burst) ├─ 下一帧回调各 ILOSRequester 写入结果,敌人读取缓存值 └─ EnemyBase 的 CheckLineOfSight() 改为读缓存,不再直接 Raycast 零改动原则:EnemyBase.IsPlayerVisible() 外部签名不变; 内部由 "直接 Raycast" 改为 "读 _losResult 字段(BatchLOSSystem 每帧写入)"。 ``` ### 15.2 ILOSRequester 接口 ```csharp // 路径: Assets/Scripts/Enemies/AI/ILOSRequester.cs namespace BaseGames.Enemies.AI { /// /// 需要视线检测的组件实现此接口,由 BatchLOSSystem 统一管理。 /// public interface ILOSRequester { /// 射线起点(通常为敌人眼睛位置)。 Vector2 LOSOrigin { get; } /// 射线终点(通常为玩家质心)。 Vector2 LOSTarget { get; } /// 遮挡层遮罩(只检测地形层)。 LayerMask LOSBlockingMask { get; } /// /// 由 BatchLOSSystem 在作业完成后回调,写入本帧视线检测结果。 /// void ReceiveLOSResult(bool hasLineOfSight); } } ``` ### 15.3 BatchLOSSystem 核心 ```csharp // 路径: Assets/Scripts/Enemies/AI/BatchLOSSystem.cs using Unity.Collections; using Unity.Jobs; using UnityEngine; namespace BaseGames.Enemies.AI { /// /// 全局批量视线检测系统。Persistent 场景常驻,或由 EnemyManager 初始化。 /// public class BatchLOSSystem : MonoBehaviour { // 同时注册的最大请求方(场景内活跃敌人 + Boss 通常 ≤ 24) private const int MAX_REQUESTERS = 32; private readonly List _requesters = new(MAX_REQUESTERS); private NativeArray _commands; private NativeArray _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(MAX_REQUESTERS, Allocator.Persistent); _results = new NativeArray(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 适配 ```csharp // 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(); _batchLOS = ServiceLocator.Get(); // 或通过 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 新增字段 ```csharp // EnemyStatsSO 新增(见 §6): [Header("视线检测")] public Vector2 EyeOffset = new Vector2(0f, 0.5f); // 眼睛相对 Transform 偏移 public LayerMask LOSBlockingMask; // Inspector 配置:勾选 Ground + Wall ```