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

1113 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 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
```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追击时用 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
```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
{
/// <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
```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<IPathAgent>()` 获取),不直接引用 `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<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
```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>() 获取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 频率规范
```csharp
// 路径: 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;
```
### 同屏敌人数量限制
```csharp
// 路径: 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`
```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
{
/// <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 接口
```csharp
// 路径: 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 核心
```csharp
// 路径: 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 适配
```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<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 新增字段
```csharp
// EnemyStatsSO 新增(见 §6
[Header("视线检测")]
public Vector2 EyeOffset = new Vector2(0f, 0.5f); // 眼睛相对 Transform 偏移
public LayerMask LOSBlockingMask; // Inspector 配置:勾选 Ground + Wall
```