docs(enemy): 寻路逼近+攻击选择器设计(感知管交战/招式自管射程/两选招模式)
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
# 敌人寻路逼近 + 攻击选择器 设计
|
||||
|
||||
> 日期:2026-07-24 状态:设计(待实现)
|
||||
> 关联:`2026-07-23-enemy-pathfind-obstruction-handling-design.md`(受阻处理)、
|
||||
> `2026-07-21-enemy-ability-config-to-so-design.md`(能力参数入 SO)、
|
||||
> `2026-07-03-enemy-ai-framework-design.md`(BrainGraph HFSM)。
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 现状
|
||||
- **导航层**(`EnemyNavAgent` / PathBerserker2d)已完整:`RequestMoveTo` 自带重寻路防抖、受阻检测、
|
||||
改道到最近可达点、NavLink(跳/落/爬)委托能力执行。
|
||||
- **执行层**(`EnemyLocomotion`)有 `LocomotionMode.Approach` 与 `MoveTo(Vector2)`,但目前没有任何
|
||||
AI 状态真正用寻路做持续逼近。
|
||||
- **决策层**(`PerceptionStateMachine`)的 `Chase` 现在只能挂**单个** `ChaseAbilityId`。E001 用的
|
||||
`ContactChaseAbility` 是"朝玩家一侧速度直冲、撞墙即停"的 committed 冲锋,**完全不走寻路**,
|
||||
被中间隔着的平台/沟拦住就停。
|
||||
- **感知层**(`PhysicsPerceptionSystem`)槽位驱动,`EnemyBase` 把槽位翻译成 `ISensor` 语义供 AI 读。
|
||||
|
||||
### 1.2 目标(本设计范围)
|
||||
新增一种与"冲锋(Chase)"并列的交战范式 —— **寻路逼近(Approach)+ 到攻击范围内选招攻击**:
|
||||
|
||||
1. 敌人沿寻路路径(含跳/落/爬 NavLink)**绕过地形持续逼近玩家**,而非直线冲。
|
||||
2. 进入某招的攻击范围后,**按攻击策略从"够得着且未冷却"的候选里选一个攻击**。
|
||||
3. **攻击时暂停逼近**;攻击结束回到逼近,重判距离/重选招,形成 逼近 → 攻击 → 重定位 → 攻击 的闭环。
|
||||
|
||||
冲锋范式(`ContactChaseAbility`)保持不变,两种范式共存、按敌人选用。
|
||||
|
||||
### 1.3 非目标
|
||||
- 不改冲锋(Chase)范式的行为。
|
||||
- 不做矩形/扇形攻击范围(本期仅圆形距离判定,`PerceptionProbe` 化留作后续扩展)。
|
||||
- 不做多目标/仇恨列表(沿用现有单一玩家目标)。
|
||||
- 不新建敌人预制体(预制体已延后,见 memory `enemy_prefabs_deferred`)。
|
||||
|
||||
## 2. 职责边界(核心原则)
|
||||
|
||||
| 层级 | 归属组件 | 负责 |
|
||||
|---|---|---|
|
||||
| **敌人级交战状态感知** | `PhysicsPerceptionSystem` | 玩家是否脱战 / 进警戒 / 进追逐等**敌人级**状态(`aggro`/`alert`/`los`/`sight` 槽) |
|
||||
| **招式级攻击触发范围** | **各攻击能力自身** | 本招"够不够得着"——射程内聚在招自己,与动画/HitBox/冷却/权重同处 |
|
||||
| **交战决策**(逼近↔攻击、选招) | AI 状态机(`PerceptionStateMachine` + `AiScript`) | 只决定进哪个态/转换条件/何时选招;不做位移、不做选招计算 |
|
||||
| **逼近位移** | `EnemyLocomotion` | 只执行寻路逼近(新增 `Pursue`) |
|
||||
| **选招计算** | `EnemyAttackSelector` | 过滤候选(射程+冷却+LOS+着地)→ 按 mode 选一个;纯逻辑、可单测 |
|
||||
| **攻击执行** | 各攻击能力 | 自己的动画/HitBox/朝向/停移 |
|
||||
|
||||
> 关键:**交战入口仍由感知系统决定**(`aggro` 槽 → `InChaseZone()` → 进 Approach);
|
||||
> **"某招够不够得着"下沉到招自身**(编辑内聚:改一个招只看一个组件,不再跨到中心感知配槽、不再对名字)。
|
||||
|
||||
## 3. 数据流
|
||||
|
||||
```
|
||||
感知系统(aggro/los/sight 槽) ──> ISensor ──> PerceptionStateMachine
|
||||
│(是否脱战/警戒/追逐) │决策
|
||||
▼ ▼
|
||||
InChaseZone()=true ──> Approach态 Attack态
|
||||
│Tick │OnEnter
|
||||
Locomotion.Pursue(LastKnown) Combat.UseBestAttack()
|
||||
│(寻路,RunSpeed) │
|
||||
▼ ▼
|
||||
EnemyBase.MoveTo ─> Nav(防抖/受阻/NavLink) EnemyAttackSelector
|
||||
(各候选自答 InAttackRange)
|
||||
│
|
||||
选中招.Execute()(自驱动画/HitBox/停移)
|
||||
```
|
||||
|
||||
- **Approach → Attack**:`Combat.HasEligibleAttack()`(有招够得着且未冷却)。
|
||||
- **Attack → Approach**:`!Combat.IsAbilityRunning()`(打完回逼近重判)。
|
||||
- **任意态 → Rest**:脱离全部感知区(`!InChaseZone && !InVisionZone`)。
|
||||
|
||||
## 4. 组件设计
|
||||
|
||||
### 4.1 `EnemyAbilitySO` 新增字段(选招/射程元数据,基类通用)
|
||||
```csharp
|
||||
[Header("调度分类与选招")]
|
||||
public AbilityCategory category = AbilityCategory.None; // None/Attack/Movement/Utility
|
||||
[Min(0f)] public float weight = 1f; // 权重随机用
|
||||
|
||||
[Header("攻击触发范围(招式自管,圆形)")]
|
||||
[Min(0f)] public float rangeRadius = 0f; // 0 = 未配置(category==Attack 时 Awake 报错)
|
||||
public Vector2 rangeOffset = Vector2.zero; // 相对敌人,X 随朝向翻转
|
||||
// 复用现有:requiresLineOfSight / requiresGrounded / priority / cooldown
|
||||
```
|
||||
- `AbilityCategory` 新增枚举(`BaseGames.Enemies.Abilities`)。选择器只收 `category == Attack`。
|
||||
- 旧的 `preferredMinRange/preferredMaxRange` 降级为纯编辑器提示(不再参与选招),后续可删。
|
||||
|
||||
### 4.2 攻击能力:自答"够不够得着"
|
||||
`EnemyAbilityBase` 增加:
|
||||
```csharp
|
||||
/// <summary>本招攻击触发范围内是否有玩家(圆形距离判定,招式自管其射程)。</summary>
|
||||
public virtual bool InAttackRange()
|
||||
{
|
||||
if (_config == null || _config.rangeRadius <= 0f || _enemy?.PlayerTransform == null) return false;
|
||||
float sign = _transform.localScale.x < 0f ? -1f : 1f;
|
||||
Vector2 origin = (Vector2)_transform.position
|
||||
+ new Vector2(_config.rangeOffset.x * sign, _config.rangeOffset.y);
|
||||
float r = _config.rangeRadius;
|
||||
return ((Vector2)_enemy.PlayerTransform.position - origin).sqrMagnitude <= r * r;
|
||||
}
|
||||
```
|
||||
- LOS 复用敌人级 `EnemyBase.IsPlayerVisible()`(不在招上单独打射线)。
|
||||
- 无物理 Overlap(有玩家 Transform,距离判定即可),零 GC、零额外物理查询。
|
||||
|
||||
### 4.3 `EnemyAttackSelector`(`EnemyBase` 持有,类比 `EnemyAbilityRegistry`)
|
||||
- **收集**:`EnemyBase.Awake` 在 `Abilities.CollectFrom` 后,从 `Abilities.All` 取 `Config.category == Attack`
|
||||
的能力为候选。
|
||||
- **可单测抽象**:选择器作用于候选接口,不直连 MonoBehaviour:
|
||||
```csharp
|
||||
public interface IAttackCandidate {
|
||||
bool CanUse { get; } // 未冷却+未运行+存活
|
||||
bool InAttackRange(); // 招式自管射程
|
||||
bool RequiresGrounded { get; }
|
||||
float Weight { get; }
|
||||
int Priority { get; }
|
||||
bool Execute();
|
||||
}
|
||||
```
|
||||
`EnemyAbilityBase`(攻击类)实现之。
|
||||
- **API**:
|
||||
```csharp
|
||||
bool HasEligible(bool hasLOS, bool grounded);
|
||||
IAttackCandidate Select(bool hasLOS, bool grounded, AttackSelectionMode mode); // 无合格→null
|
||||
```
|
||||
- **单招合格判定**:`c.CanUse && c.InAttackRange() && (!requiresLOS(此招) || hasLOS)
|
||||
&& (!c.RequiresGrounded || grounded)`。
|
||||
- `requiresLineOfSight` 读各招 `Config`;`hasLOS = enemy.IsPlayerVisible()`,`grounded` 由 `EnemyBase` 提供。
|
||||
- **选招(AttackSelectionMode)**:
|
||||
- `WeightedRandom`:合格集内按 `Weight` 加权随机(`UnityEngine.Random`)。
|
||||
- `Priority`:取 `Priority` 最高;并列取候选列表首个(确定性)。
|
||||
- **根因校验**:收集后若某 `category==Attack` 候选 `rangeRadius<=0` → `LogError`(永远够不着的静默 bug 暴露);
|
||||
若敌人配了 ApproachAttack 但候选集为空 → `LogError`。
|
||||
|
||||
### 4.4 `EnemyStatsSO` 新增
|
||||
```csharp
|
||||
public AttackSelectionMode attackSelectionMode = AttackSelectionMode.WeightedRandom;
|
||||
```
|
||||
`AttackSelectionMode { WeightedRandom, Priority }`(`BaseGames.Enemies` 或 `.Abilities`)。
|
||||
|
||||
### 4.5 `ICombatant` 扩展(`EnemyBrainContext` 实现)
|
||||
```csharp
|
||||
bool HasEligibleAttack(); // = selector.HasEligible(enemy.IsPlayerVisible(), enemy.Movement.IsGrounded)
|
||||
bool UseBestAttack(); // sel=selector.Select(...); return sel != null && sel.Execute();
|
||||
```
|
||||
> 着地读 `EnemyBase.Movement.IsGrounded`(`EnemyMovement` 已暴露);如需可在 `EnemyBase` 加一层便捷只读属性。
|
||||
- 动态量(LOS/着地/选招模式)在 `EnemyBrainContext` 侧采集后委托选择器,选择器保持无敌人耦合。
|
||||
|
||||
### 4.6 `IEnemyLocomotion` 扩展
|
||||
```csharp
|
||||
void Pursue(Vector2 target); // 寻路逼近原语:设 Approach 模式 + RunSpeed + 每帧 Nav.RequestMoveTo(target)
|
||||
```
|
||||
- Vector2 驱动(不耦合 Transform),底层已防抖/受阻/NavLink。
|
||||
- 现有 `Approach(Transform)` 可委托 `Pursue(target.position)`。
|
||||
|
||||
### 4.7 `PerceptionStateMachine`:新增 ApproachAttack 交战风格(opt-in)
|
||||
`Config` 新增:
|
||||
```csharp
|
||||
public EngagementStyle Engagement = EngagementStyle.ChaseAbility; // ChaseAbility(现状) | ApproachAttack(新)
|
||||
public string ApproachState = "Approach";
|
||||
public string AttackState = "Attack";
|
||||
```
|
||||
`EngagementStyle == ApproachAttack` 时,把原单一 Chase 能力节点换成两态子机(`ChaseAbility` 风格拓扑不变):
|
||||
|
||||
- **Idle/Patrol/Alert** → `Approach` When `canChase`(沿用现有 `InChaseZone` / 冷却门条件)
|
||||
- **Approach**(Locomotion 驱动)
|
||||
- `OnEnter/Tick`:`Locomotion.Pursue(Sensor.LastKnown)`
|
||||
- `OnExit`:`Locomotion.Stop()`
|
||||
- → `Attack` When `Combat.HasEligibleAttack()`
|
||||
- → `Rest` When `!InChaseZone && !InVisionZone`
|
||||
- **Attack**(能力驱动,无固定 id)
|
||||
- `OnEnter`:`Locomotion.Stop()` + `Combat.UseBestAttack()`(攻击时暂停逼近)
|
||||
- → `Approach` When `!Combat.IsAbilityRunning()`(打完回逼近重判/重选招)
|
||||
- → `Rest` When `!InChaseZone && !InVisionZone`(打完且脱离感知区)
|
||||
- **Rest** → `Patrol`(不变)
|
||||
- 全局 `Died` → `Death`(不变)
|
||||
|
||||
## 5. 边界与错误处理
|
||||
|
||||
| 情形 | 行为 |
|
||||
|---|---|
|
||||
| 无 NavLink 隔沟够不到玩家 | 走现有受阻逻辑 → 停在最近可达点等待(仍在 chase 区,Approach 态保持) |
|
||||
| 配了 ApproachAttack 但无攻击候选 / 招 `rangeRadius<=0` | `Awake` 显式 `LogError`(根因暴露,不静默兜底,CLAUDE.md §6) |
|
||||
| 玩家走出所有招射程 | 无合格攻击 → 持续逼近 |
|
||||
| 攻击被受击打断 | 能力中断 → Attack.Tick 见 `!IsAbilityRunning` → 回 Approach |
|
||||
| 玩家脱离感知区 | `!InChaseZone && !InVisionZone` → Rest → Patrol |
|
||||
| 关闭 Domain Reload | 选择器无静态态;`UnityEngine.Random` 运行时安全(见 memory `domain_reload_disabled`) |
|
||||
|
||||
## 6. 测试
|
||||
|
||||
### 6.1 EditMode 单测(`EnemyAttackSelector`)
|
||||
用假候选(实现 `IAttackCandidate`)+ 复用 `Assets/Tests/EditMode/AI/Fakes` 范式:
|
||||
- 合格过滤:冷却中 / 射程外 / 无 LOS(招 requiresLOS 且 hasLOS=false)/ 需着地但腾空 → 各自被排除。
|
||||
- `Priority` 模式:多招合格取最高 `Priority`,并列取首个(确定性断言)。
|
||||
- `WeightedRandom` 模式:仅从合格集选(多次采样断言从不选到不合格招);单一合格招 → 必选它(确定性)。
|
||||
- 空候选 / 全不合格 → `Select` 返回 null、`HasEligible` 为 false。
|
||||
|
||||
### 6.2 手动 / PlayMode
|
||||
- 一个 `EngagementStyle=ApproachAttack` 的测试敌人(TestRoomA 内临时搭,或改 ChaoFeng):
|
||||
跨平台缺口(跳 NavLink)逼近玩家 → 到某招射程停 → 选招攻击 → 打完重定位 → 玩家逃出感知区回巡逻。
|
||||
- 验证攻击射程 Gizmo 与实际触发一致。
|
||||
|
||||
## 7. 脚手架 / 规范合规(CLAUDE.md §2/§6)
|
||||
|
||||
- 无新资产类型(仅 `EnemyAbilitySO`/`EnemyStatsSO` 加字段)。
|
||||
- 能力/角色向导需按能力类型设 `category`(攻击类→`Attack`)与默认 `weight`/`rangeRadius`——列入实现计划。
|
||||
- 攻击能力在编辑器画自身射程 Gizmo(`rangeRadius`+`rangeOffset`,随朝向翻转),设计师摆招即见。
|
||||
- 自检工具(`SOValidationRunner`)加一条:`category==Attack` 必须 `rangeRadius>0`。
|
||||
|
||||
## 8. 涉及文件(预估)
|
||||
|
||||
- 改:`EnemyAbilitySO.cs`(+category/weight/rangeRadius/rangeOffset)、`EnemyStatsSO.cs`(+attackSelectionMode)、
|
||||
`EnemyAbilityBase.cs`(+InAttackRange/IAttackCandidate 实现)、`EnemyBase.cs`(+选择器持有/IsGrounded 暴露)、
|
||||
`EnemyBrainContext.cs`(+HasEligibleAttack/UseBestAttack)、`ICombatant.cs`、`IEnemyLocomotion.cs`(+Pursue)、
|
||||
`EnemyLocomotion.cs`(+Pursue 实现)、`PerceptionStateMachine.cs`(+ApproachAttack 拓扑)。
|
||||
- 新:`AbilityCategory.cs`、`AttackSelectionMode.cs`、`IAttackCandidate.cs`、`EnemyAttackSelector.cs`、
|
||||
`EnemyAttackSelectorTests.cs`(EditMode)。
|
||||
- 编辑器:攻击能力射程 Gizmo;能力向导 category/range 设置;`SOValidationRunner` 校验项。
|
||||
Reference in New Issue
Block a user