44 KiB
07 · 敌人模块
命名空间
BaseGames.Enemies、BaseGames.Enemies.AI、BaseGames.Enemies.Navigation、BaseGames.Enemies.Boss.Patterns
程序集 见各 asmdef
路径Assets/Scripts/Enemies/
依赖BaseGames.Combat、Kybernetik.Animancer、BehaviorDesigner.Runtime、PathBerserker2d
目录
- EnemyBase
- EnemyStats
- EnemyMovement
- EnemyCombat
- EnemyNavAgent
- EnemyStatsSO
- EnemyAnimationConfigSO
- Behavior Designer 任务类目录
- BossBase
- BossOrchestrator
- AttackPatternSO 与 TelegraphSystem
- DeathShade(死亡遗骸)
- Enemy Prefab 层级结构
- LootTableSO 与 LootResolver
- 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 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<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 读写 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
// 路径: 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(追击时用 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
// 路径: 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。通过接口隔离实现导航层可替换。
// 路径: Assets/Scripts/Enemies/Navigation/IPathAgent.cs
namespace BaseGames.Enemy
{
/// <summary>
/// 导航代理抽象接口。BD Task 和 EnemyBase 只依赖此接口,不依赖具体导航库。
/// 实现:EnemyNavAgent(PathBerserker2d);测试用 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>()获取),不直接引用EnemyNavAgent或PB2dAgent。
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 共用以下 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
// 路径: 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.cs(BehaviorTree 初始化段)
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()中调用FindObjectsOfType、GetComponentsInChildren等高开销 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 优化:场景中同时存在 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 接口
// 路径: 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