467 lines
38 KiB
Markdown
467 lines
38 KiB
Markdown
# Enemy AI Logic
|
||
|
||
> **Status**: In Review (revised 2026-04-27 — aligned with revised Combat Logic GDD)
|
||
> **Author**: SepComet + Claude
|
||
> **Last Updated**: 2026-04-27
|
||
> **Implements Pillar**: 形态即战术 (Form is Tactics), 战场即信息 (The Field is Information), 操作优于数值 (Skill Over Stats)
|
||
|
||
## Overview
|
||
|
||
Enemy AI Logic 是夜裔的敌人行为决策系统——控制每个敌人的追逐、攻击、冷却和伤害响应。系统采用 ADR-005 定义的轻量级逐类型有限状态机(FSM),基座为 Idle → Chase → Attack → Cooldown 四态循环,不同类型的差异化集中在 Attack 状态中。它为每种敌人类型编码了"鼓励使用哪种形态"的行为模式:Swarm 集群包围迫使切雾形 AOE,Brute 高伤害单次重击创造人形弹反窗口,Stalker 高速冲刺留给狼躯直线对撞。
|
||
|
||
玩家不与 Enemy AI Logic 直接交互——他们**阅读**敌人行为。一个 Brute 举起重击前摇,就是战场上最清晰的信息:"现在是弹反的时机"。一个 Stalker 开始低伏冲刺,就是"切狼躯对撞或闪避"。Enemy AI Logic 是"战场即信息"支柱的引擎端:它确保每种敌人行为都是可读、可预测、可学习的——因为玩家每一个切换决策都建立在看懂敌人行为之上。没有这个系统,敌人只是静止的靶子,形态切换失去战术意义,"形态即战术"支柱崩塌。
|
||
|
||
## Player Fantasy
|
||
|
||
Enemy AI Logic 服务于一个独特的双重幻想:对玩家而言,它是**读场**的快感——你看见一群 Swarm 围过来,心里知道"切雾形";看见 Brute 抬起重拳的前摇,肌肉记忆已经按下弹反。对设计师而言,它是**编排**的权力——每个敌人的行为是一段为玩家形态切换谱写的节奏线,敌人不是"AI",是作曲家留在战场上的音符。
|
||
|
||
这个幻想与"战场即信息"支柱直接对齐。当 Enemy AI 运转良好时,玩家不会说"这个AI写得不错"——他们会说"我看懂了,我知道该切什么"。敌人的每一个状态转换(Chase→Attack、Attack→Cooldown)都是玩家决策链中的一个信息节点。Brute 从 Chase 进入 Attack 的那个 0.5s 抬手前摇,就是游戏在问玩家:"你准备好弹反了吗?"
|
||
|
||
参考 Hades 的敌人可读性——每个敌人的攻击前摇都清晰到你可以闭眼计时——和 Vampire Survivors 的密度压迫感——敌人数量本身构成了"你必须移动或切换"的推力。夜裔的 Enemy AI 将两者融合:**密度创造压力,可读性创造决策空间**。玩家不是被数字淹没,而是被清晰的信息包围——每一个敌人都是一张"需要你切换形态"的明牌。
|
||
|
||
## Detailed Design
|
||
|
||
### Core Rules
|
||
|
||
**1. Per-Frame Decision Pipeline**
|
||
|
||
每一帧,Enemy AI Logic 对每个活跃敌人执行一次 `ComputeAction(enemy, player)`:
|
||
|
||
```
|
||
1. DEAD CHECK: enemy.Health <= 0 → 强制 Dead 状态,返回 AICommand.Dead
|
||
2. DELTA CLAMP: effectiveDt = min(deltaTime, MAX_DELTA) where MAX_DELTA = 0.05s — 防止帧率峰值跳过攻击前摇或冷却计时器
|
||
3. STATE TRANSITION: 检查当前状态的转换条件(距离、计时器、player.IsDead)
|
||
4. ACTION OUTPUT: 当前状态产生一个 AICommand
|
||
5. RETURN: AICommand 传递给 CombatCoordinator 进行攻击解析
|
||
```
|
||
|
||
**DeltaTime 上限**:`MAX_DELTA = 0.05s` 确保即使出现大幅帧率峰值(如 GC 暂停),单个 Update() 调用也不会跳过整个攻击前摇(Stalker 最短,0.15s)或冷却(Swarm 最短,0.6s)。此上限与 Form Switch SM 的 deltaTime 上限策略一致。
|
||
|
||
**2. Aggro Rules**
|
||
|
||
- 每个敌人有 `DetectionRange`。玩家进入检测范围 → aggro 激活
|
||
- 一旦 aggro 激活,敌人始终知道玩家位置(不会丢失目标)
|
||
- Aggro 不可逆——敌人不会"失去兴趣"
|
||
- 生成时在 Idle 状态,不 aggro;玩家进入 DetectionRange 后立即转换到 Chase
|
||
|
||
**3. Health & Damage Reception**
|
||
|
||
- Enemy AI Logic 通过 `ApplyDamage(DamageResult)` 接口接收 Combat Logic 的伤害输出
|
||
- 从 enemy.Health 减去 DamageResult.FinalDamage
|
||
- 如果 Health <= 0:标记 IsDead = true,发射 `OnEnemyDied(enemyId, enemyType, killerForm)`
|
||
- 应用 KnockbackDistance 到敌人位置(沿攻击方向位移)
|
||
- **不使用按形态的伤害抗性**——几何形状交互本身就是形态优势(单点 vs. AOE vs. 直线)
|
||
|
||
**4. Spacing Behavior**
|
||
|
||
- Chase 状态:敌人以 MoveSpeed 向玩家位置移动
|
||
- 到达 AttackRange 时:转换到 Attack 状态
|
||
- 如果因击退而超出 AttackRange:保持在 Attack 状态(攻击动画已启动),攻击完成后进入 Cooldown
|
||
- 没有"后退/保持距离"行为——所有 MVP 敌人都是近战逼近型
|
||
- **Kiting mitigation**: 所有敌人均为近战逼近型,玩家可无限后退风筝。主要缓解措施为竞技场边界(半径 30,见 Wave Manager GDD)——边界作为硬性约束防止无限后退。Stalker 的高速(7.0)作为次级缓解——Stalker 会追上后退的玩家。如果原型验证显示风筝仍是主要问题,备选方案包括:Stalker 速度爆发、Swarm 区域封锁,或竞技场收缩机制。
|
||
|
||
**5. Enemy Type Configuration**
|
||
|
||
每个敌人类型由其 `EnemyTypeConfig` 定义:
|
||
|
||
| 参数 | 类型 | 描述 |
|
||
|------|------|------|
|
||
| TypeId | int | 唯一类型标识符 |
|
||
| MaxHealth | float | 最大生命值 |
|
||
| MoveSpeed | float | Chase 状态移动速度(单位/秒) |
|
||
| DetectionRange | float | 检测玩家的距离 |
|
||
| AttackRange | float | 发起攻击的距离 |
|
||
| AttackWindup | float | 攻击前摇(秒),期间有动画提示 |
|
||
| AttackShape | HitShape | 攻击判定形状 |
|
||
| AttackBaseDamage | float | 攻击基础伤害 |
|
||
| CooldownDuration | float | 攻击后冷却时间(秒) |
|
||
| Weight | float | 抗性(1-10,影响击退) |
|
||
| HitShape | HitShape | 受击判定形状(通常为 Circle) |
|
||
| EncouragedForm | FormType | 此敌人类型设计上鼓励的玩家形态 |
|
||
|
||
### States and Transitions
|
||
|
||
**Base FSM (ADR-005),所有类型共享:**
|
||
|
||
```
|
||
Idle ──→ Chase ──→ Attack ──→ Cooldown
|
||
↑ │ │ │
|
||
│ │ │ │
|
||
└─────────┴──────────┴──────────┘
|
||
如果玩家超出 DetectionRange + margin 且不在 Chase 中,回到 Idle
|
||
Chase 状态额外退出:玩家 IsDead → 强制 Idle
|
||
```
|
||
|
||
| 状态 | 行为 | 转换条件 |
|
||
|------|------|----------|
|
||
| **Idle** | 待机/巡逻(当前帧无动作)。扫描玩家位置。 | → Chase:玩家进入 DetectionRange |
|
||
| **Chase** | 以 MoveSpeed 向玩家移动。面向玩家。 | → Attack:玩家进入 AttackRange |
|
||
| **Attack** | 执行 Windup → 攻击判定 → 收招动画。攻击判定帧产生 AttackData 交给 Combat Logic。 | → Cooldown:攻击动画完成 |
|
||
| **Cooldown** | 等待 CooldownDuration。面向玩家但不移动。 | → Chase:计时器结束,且玩家在 DetectionRange + 20% margin 内;否则 → Idle |
|
||
| **Dead** | 不做任何逻辑。Health <= 0 时立即进入。 | 无(不可逆) |
|
||
|
||
**Dead 状态说明**:Dead 是衍生状态——当 Health <= 0 时,无论当前在哪个 FSM 状态,立即进入 Dead。Combat Logic 通过 `OnKill(IsKillingBlow=true)` 触发此转换。L1 适配层负责销毁 GameObject 和触发死亡 VFX。
|
||
|
||
### Per-Type Attack Parameters (MVP — 3 Types)
|
||
|
||
#### Swarm(小型集群敌人)
|
||
|
||
```
|
||
EnemyTypeConfig {
|
||
TypeId: 1, MaxHealth: 12, MoveSpeed: 4.5,
|
||
DetectionRange: 12.0, AttackRange: 1.5,
|
||
AttackWindup: 0.2s, CooldownDuration: 0.6s,
|
||
AttackShape: Circle(r=0.8), AttackBaseDamage: 6,
|
||
Weight: 1.0, HitShape: Circle(r=0.4),
|
||
EncouragedForm: Mist
|
||
}
|
||
```
|
||
|
||
**设计意图**:大量小个体同时进攻 → Human 扇形一次只命中 1 个,Wolf 矩形一次最多 3 个,Mist 圆形 AOE (r=3.8) 一次清屏。Swarm 的几何特性本身——多、小、散——就是"切雾形"的信号。AttackWindup 短(0.2s)使弹反窗口紧凑,进一步降低 Human 效率。
|
||
|
||
#### Brute(重型单体敌人)
|
||
|
||
```
|
||
EnemyTypeConfig {
|
||
TypeId: 2, MaxHealth: 60, MoveSpeed: 2.0,
|
||
DetectionRange: 10.0, AttackRange: 3.0,
|
||
AttackWindup: 0.5s, CooldownDuration: 1.2s,
|
||
AttackShape: Circle(r=1.2), AttackBaseDamage: 25,
|
||
Weight: 8.0, HitShape: Circle(r=0.9),
|
||
EncouragedForm: Human
|
||
}
|
||
```
|
||
|
||
**设计意图**:慢速、高伤害、明显前摇 → 完美的弹反目标。Human 弹反窗口 0.15s 精准匹配 Brute 的 0.5s 前摇(玩家看到抬手→反应→按弹反)。Mist AOE 低伤害(base 8)清不动 60 HP。Wolf 可以冲刺爆发但风险很高——如果在 Wolf Recovery(0.15s 不可攻击)期间 Brute 攻击命中,玩家承受 25 伤害无弹反机会。Brute 的 Weight=8 使击退接近零——它不会因被击而位移,弹反是唯一的"打断"方式。
|
||
|
||
#### Stalker(高速冲刺敌人)
|
||
|
||
```
|
||
EnemyTypeConfig {
|
||
TypeId: 3, MaxHealth: 30, MoveSpeed: 7.0,
|
||
DetectionRange: 14.0, AttackRange: 6.0,
|
||
AttackWindup: 0.15s, CooldownDuration: 0.8s,
|
||
AttackShape: Rect(w=1.5, l=6.0), AttackBaseDamage: 18,
|
||
Weight: 3.0, HitShape: Circle(r=0.5),
|
||
EncouragedForm: Wolf
|
||
}
|
||
```
|
||
|
||
**设计意图**:高速从远距离发动冲刺 → Wolf 的 Rect 冲刺 (w=1.0, l=5.0) 形成"对撞"判定。Stalker 从 6.0 距离启动冲刺,Wolf 也从约 5.0 距离启动——两者矩形重叠的可能性很高。Wolf 是**最优**反制手段(爆发伤害、对撞优势),但 Mist 在 Stalker 冲刺落点后可提供次要反制(AOE 覆盖已接近的 Stalker,4 次命中击杀,在 Stalker 冷却期间完成)。Human 弹反理论上可行(0.15s 窗口捕捉 0.15s 前摇),但时机极紧——这是高阶技巧。Stalker 前摇(0.15s)快于人类视觉反应时间(~200-250ms),因此玩家无法纯粹靠反应弹反 Stalker——必须通过预判和站位来应对。"阅读 Stalker 行为"的方式是观察其低伏冲刺姿态和 violet streak VFX,而非在起手式后才开始反应。
|
||
|
||
**Wolf vs Stalker 对撞设计**:当 Wolf Rect 与 Stalker 冲刺 Rect 相交时发生特殊"对撞"结果:Stalker 攻击被 nullified(Wolf 赢),Stalker 额外眩晕 0.45s。Wolf 玩家不承受伤害。这使 Wolf 对 Stalker 的优势明确且可感知——不同于仅靠数值差异(22 vs 18 = +4 HP,无法被感知)。对撞由 Combat Logic 在招架阶段检测——两个 AttackSource.Enemy 的矩形与一个 AttackSource.Player 的矩形相交。
|
||
|
||
### Interactions with Other Systems
|
||
|
||
| System | Direction | Interface |
|
||
|--------|-----------|-----------|
|
||
| **Combat Logic** | Upstream | 通过 `ApplyDamage(DamageResult)` 接收伤害、击退。敌人攻击时产生的 `AttackData` 注入 Combat Logic 的 `ResolveAttacks`。 |
|
||
| **Combat Logic** | Downstream | Enemy AI 为每个 Attack 状态敌人产出 `AttackData`(填充 HitShape、BaseDamage、Direction 等),提交给 Combat Logic 判定。 |
|
||
| **Wave Manager Logic** | Upstream | Wave Manager 负责生成敌人实例并注入 `EnemyTypeConfig`。通知 Enemy AI "新敌人已生成"。 |
|
||
| **Wave Manager Logic** | Downstream | Enemy AI 发射 `OnEnemyDied` → Wave Manager 追踪存活敌人数量,判断波次是否结束。 |
|
||
| **VFX Spawner (L1)** | Downstream | 发射 `OnEnemyStateChanged`(状态转换时)、`OnEnemyAttackWindup`(前摇开始时)、`OnEnemyDied` |
|
||
| **Audio Player (L1)** | Downstream | 发射 `OnEnemyAttackWindup`(前摇音效)、`OnEnemyHit`(受击音效)、`OnEnemyDied`(死亡音效) |
|
||
| **Boss AI Logic** | Downstream | Boss AI 继承 Enemy AI 的 FSM 基座和 EnemyTypeConfig,扩展 BossPhase 维度 |
|
||
|
||
**Player State 获取**:Enemy AI 通过 CombatCoordinator 每帧传入的 `PlayerState` 参数获取玩家位置、当前形态、是否弹反激活、和 `IsDead` 状态。PlayerState 由 CombatCoordinator 在帧开始时计算一次,然后传递给所有 `ComputeAction()` 调用——不在每个敌人内部独立查询。这避免了 200 次独立的接口调用,并与 ADR-005 的参数式签名一致。
|
||
|
||
**AttackData 流向**:Enemy AI 不直接调用 Combat Logic。每帧 Enemy AI 输出 `List<AICommand>`,其中 Attack 命令携带 `AttackData`。CombatCoordinator(L0)将此 List 与玩家攻击的 `AttackData` 合并,再统一调用 `ResolveFrame()`。
|
||
|
||
**Enemy AttackData 字段默认值**:敌人产生的 AttackData 必须设置以下字段。标记为 [固定] 的字段对所有敌人攻击均不变;标记为 [配置] 的字段来自 EnemyTypeConfig。
|
||
|
||
| AttackData 字段 | 敌人值 | 来源 |
|
||
|-----------------|--------|------|
|
||
| `AttackId` | 单调递增,CombatCoordinator 分配 | CombatCoordinator |
|
||
| `AttackGroupId` | 0(敌人攻击不在同一激活内分组) | [固定] |
|
||
| `Source` | `AttackSource.Enemy` | [固定] |
|
||
| `Origin` | 敌人当前 Position | EnemyState |
|
||
| `Direction` | 敌人当前 FacingAngle | EnemyState |
|
||
| `HitShape` | 来自 EnemyTypeConfig.AttackShape | [配置] |
|
||
| `SourceForm` | `FormType.None` | [固定] |
|
||
| `BaseDamage` | 来自 EnemyTypeConfig.AttackBaseDamage | [配置] |
|
||
| `CritChanceStat` | 0.0(敌人不会暴击) | [固定] |
|
||
| `CritMultiplier` | 1.0(敌人不会暴击) | [固定] |
|
||
| `KnockbackForce` | 来自 EnemyTypeConfig(可选,当前所有 MVP 类型均为 0) | [配置] |
|
||
| `InnerCritRadius` | 0.0(敌人无内圈暴击区) | [固定] |
|
||
| `IsParry` | false(敌人不弹反) | [固定] |
|
||
|
||
**AICommand 结构**:`AICommand` 是一个 union 结构体,包含 `CommandType` 枚举和携带数据:
|
||
```
|
||
AICommand {
|
||
CommandType Type; // MoveTo, Attack, Idle
|
||
Vector3 TargetPosition; // MoveTo 的目标位置
|
||
AttackData AttackData; // Attack 的攻击数据(仅在 Type==Attack 时有效)
|
||
}
|
||
```
|
||
CombatCoordinator 每帧收集所有 AICommand,从中提取 Attack 命令的 AttackData,并将其与玩家攻击合并。
|
||
|
||
**Enemy 实例工厂**:Enemy AI Logic 提供 `SpawnEnemy(typeConfig, position, facingAngle)` 工厂方法,由 Wave Manager 在生成敌人时调用。此方法:
|
||
1. 创建新的 `EnemyState` 实例,从 `EnemyTypeConfig` 初始化 Position、FacingAngle、HitShape、Health、Weight
|
||
2. 将实例添加到 Enemy AI 的内部 `EnemyState[]` 数组(CombatCoordinator 每帧读取)
|
||
3. 返回 `enemyId`(在 Enemy AI 内部单调递增分配)
|
||
4. 如果玩家已在 DetectionRange 内,直接进入 Chase 状态;否则进入 Idle
|
||
5. L1 适配层并行创建对应的 GameObject(通过订阅 `OnEnemySpawned` 事件)
|
||
|
||
```
|
||
int SpawnEnemy(EnemyTypeConfig config, Vector3 position, float facingAngle)
|
||
```
|
||
|
||
此工厂方法是 Enemy AI 与 Wave Manager 之间的硬契约——Wave Manager 不可在没有它的情况下运作。
|
||
|
||
## Formulas
|
||
|
||
### Aggro Detection
|
||
|
||
The aggro formula is defined as:
|
||
|
||
`isAggroed = distance(enemyPos, playerPos) <= DetectionRange`
|
||
|
||
**Variables:**
|
||
|
||
| Variable | Symbol | Type | Range | Description |
|
||
|----------|--------|------|-------|-------------|
|
||
| Enemy position | `enemyPos` | Vector3 | any | Enemy world position (L0 coords) |
|
||
| Player position | `playerPos` | Vector3 | any | Player world position (L0 coords) |
|
||
| Detection range | `DetectionRange` | float | 10.0–14.0 | Per EnemyTypeConfig |
|
||
|
||
**Output**: Boolean — once true, stays true for this enemy's lifetime.
|
||
|
||
---
|
||
|
||
### Chase Movement
|
||
|
||
**Chase Movement formula with guard**:
|
||
|
||
`direction = playerPos - enemyPos`
|
||
`if |direction| ≤ EPSILON: skip movement (enemy at player position)`
|
||
`else: newPosition = enemyPos + normalize(direction) × MoveSpeed × deltaTime`
|
||
|
||
This guards against division-by-zero when an enemy reaches the player's exact position (common at high density with no enemy-enemy collision). `EPSILON = 0.001f` (from ADR-003).
|
||
|
||
**Variables:**
|
||
|
||
| Variable | Symbol | Type | Range | Description |
|
||
|----------|--------|------|-------|-------------|
|
||
| Move speed | `MoveSpeed` | float | 2.0–7.0 | Per EnemyTypeConfig |
|
||
| Delta time | `deltaTime` | float | 0–0.033s | Frame delta (typically ~0.016s at 60fps) |
|
||
|
||
**Output Range**: 0.032–0.231 units/frame | No acceleration/deceleration — instant speed.
|
||
|
||
---
|
||
|
||
### Attack Range Check
|
||
|
||
The attack range formula is defined as:
|
||
|
||
`canAttack = distance(enemyPos, playerPos) <= AttackRange AND currentState == Chase`
|
||
|
||
**Variables:**
|
||
|
||
| Variable | Symbol | Type | Range | Description |
|
||
|----------|--------|------|-------|-------------|
|
||
| Attack range | `AttackRange` | float | 1.5–6.0 | Per EnemyTypeConfig |
|
||
|
||
**Output**: Boolean. Attack starts on the frame the condition becomes true.
|
||
|
||
---
|
||
|
||
### Damage Reception
|
||
|
||
The damage reception formula is defined as:
|
||
|
||
`newHealth = currentHealth - finalDamage`
|
||
|
||
**Variables:**
|
||
|
||
| Variable | Symbol | Type | Range | Description |
|
||
|----------|--------|------|-------|-------------|
|
||
| Current health | `currentHealth` | float | 0–MaxHealth | Pre-hit health |
|
||
| Final damage | `finalDamage` | float | 0–150 | From Combat Logic DamageResult (already includes crit) |
|
||
|
||
**Output Range**: 0 to MaxHealth | Clamped at 0 (cannot go negative). If newHealth <= 0 → IsDead = true.
|
||
|
||
**Design Intent**: Enemy AI applies damage as-received. No resistance, no armor, no mitigation. Combat Logic owns the damage formula; Enemy AI is a pure receiver. This keeps the systems single-responsibility and makes enemy durability entirely transparent to the player (an enemy with 60 HP always takes exactly the DamageResult.FinalDamage).
|
||
|
||
---
|
||
|
||
### Knockback Application
|
||
|
||
The knockback formula is defined as:
|
||
|
||
`newPosition = enemyPos + attackDirection × knockbackDistance`
|
||
|
||
**Variables:**
|
||
|
||
| Variable | Symbol | Type | Range | Description |
|
||
|----------|--------|------|-------|-------------|
|
||
| Attack direction | `attackDirection` | Vector3 | unit vector | From attack origin to hit point (Combat Logic) |
|
||
| Knockback distance | `knockbackDistance` | float | 0–15 | From DamageResult.KnockbackDistance |
|
||
|
||
**Output Range**: 0–15 units displacement | Computed by Combat Logic; Enemy AI applies it. Weight already factored in Combat Logic's knockback formula.
|
||
|
||
## Edge Cases
|
||
|
||
- **If enemy spawns with player already in DetectionRange**: Skip Idle, immediately enter Chase. No "wake-up" animation needed — enemy is already alert.
|
||
- **If Health <= 0 and ApplyDamage is called again (double-kill)**: Ignore the call. Dead enemies do not receive damage events. The first IsDead=true transition is final.
|
||
- **If deltaTime = 0 (game paused)**: Skip entire `ComputeAction` for all enemies. No state transitions, no movement, no attack timers advance.
|
||
- **If enemy has no EnemyTypeConfig assigned**: Log error, skip enemy in AI loop. Enemy does not move or attack. This is a data integrity bug, not a runtime edge case.
|
||
- **If AttackRange > DetectionRange (misconfigured EnemyTypeConfig)**: Clamp AttackRange to DetectionRange at config load time. An enemy can't attack what it can't detect.
|
||
- **If MoveSpeed = 0 (misconfigured)**: Enemy stays in Chase forever but never reaches AttackRange. Log warning at config load. Design fix: set MoveSpeed > 0.
|
||
- **If knockback pushes enemy beyond DetectionRange + 20% margin during Cooldown**: Normal Cooldown → Idle transition applies. Enemy returns to Idle (scans for player again). This creates a legitimate gameplay interaction — player can "punt" an enemy out of combat temporarily.
|
||
- **Enemy stacking at player position (magnet convergence)**: Without enemy-enemy collision (MVP deferral), all enemies in Chase converge on the player's exact position. This creates: (a) unreadable visual blob — enemies indistinguishable, (b) degenerate AOE damage — single Mist circle hits all stacked enemies, (c) stacked damage to player — up to 20 Swarms × 6 damage = 120 damage/frame. Mitigation for MVP: Swarm has per-frame player damage cap of 50 (max 8 simultaneous Swarm hits). Full enemy-enemy separation (minimum distance repulsion) deferred to vertical slice. Known risk documented.
|
||
- **If player dies while enemies are in any non-Dead state**: Enemies enter Cooldown (if attacking) or Idle. L1/DeathRespawn rules manage the overall game state. Enemy AI does NOT continue attacking a dead player.
|
||
- **If an enemy attack fires but player has already killed the enemy in the same frame**: The batched ResolveAttacks pipeline handles this — all attacks resolve against pre-frame state. If enemy A's attack and the killing blow to enemy A are in the same frame, both resolve. The enemy's attack can still hit the player even as the enemy dies.
|
||
- **If 200+ enemies are active simultaneously**: Each `ComputeAction` call is O(1) — a single distance check + state machine step. At 200 enemies this is ~0.2ms budget. No per-frame allocation (struct AICommand, pre-allocated list).
|
||
- **If enemy is knockbacked into another enemy**: No collision response between enemies in MVP. Enemies can overlap. Enemy-enemy collision is deferred to vertical slice polish.
|
||
- **If Brute's AttackWindup (0.5s) is interrupted by player parry (0.15s window overlapping attack shape)**: This is handled by Combat Logic's parry system. Parry success → enemy enters stagger (0.45s stun), attack is nullified. Enemy AI receives `OnParrySuccess` event → force transition to Cooldown after stagger ends. This is the core Brute→Human interaction and must work correctly.
|
||
|
||
## Dependencies
|
||
|
||
| System | Layer | Type | Hard/Soft | Interface |
|
||
|--------|-------|------|-----------|-----------|
|
||
| **Combat Logic** | L0 | Upstream | Hard | Receives `DamageResult` via `ApplyDamage()`. Produces `AttackData` for enemy attacks to feed into `ResolveAttacks()`. Enemy AI cannot function without hit resolution and damage feedback. |
|
||
| **Wave Manager Logic** | L0 | Upstream | Hard | Wave Manager spawns enemy instances and injects `EnemyTypeConfig`. Without it, enemies don't exist. |
|
||
| **Form Switch SM** | L0 | Upstream | Soft | Reads player position and current form via `IPlayerStateProvider`. Soft because Enemy AI could function with position-only (ignoring form), but optimal play requires form awareness for certain enemy behaviors. |
|
||
| **Boss AI Logic** | L0 | Downstream | Soft | Boss AI inherits the base FSM and `EnemyTypeConfig` structure. Enemy AI must expose virtual methods for Boss AI to override (per ADR-005). |
|
||
| **Wave Manager Logic** | L0 | Downstream | Hard | Emits `OnEnemyDied(enemyId, enemyType, killerForm)` for Wave Manager to track remaining enemies and determine wave completion. |
|
||
| **VFX Spawner (L1)** | L1 | Downstream | Soft | Emits state change, windup, and death events for visual feedback. Game works without VFX but feels broken. |
|
||
| **Audio Player (L1)** | L1 | Downstream | Soft | Emits windup, hit, and death events for audio feedback. Same as VFX — functional without but degraded. |
|
||
| **Death/Respawn Rules** | L0 | Downstream | Soft | Enemy AI emits `OnEnemyDied` which Death/Respawn may listen to for scoring/tracking. No direct dependency required for MVP. |
|
||
|
||
**Hard dependencies** (system cannot function without): Combat Logic, Wave Manager Logic (spawning).
|
||
|
||
**Soft dependencies** (enhanced by but works without): Form Switch SM, VFX Spawner, Audio Player, Death/Respawn Rules, Boss AI Logic.
|
||
|
||
## Tuning Knobs
|
||
|
||
| Parameter | Default | Safe Range | Too Low | Too High |
|
||
|-----------|---------|------------|---------|----------|
|
||
| `MoveSpeed` (Swarm) | 4.5 | 3.0–6.0 | Swarm never reaches player, no pressure | Swarm is unkitable, Mist form mandatory |
|
||
| `MoveSpeed` (Brute) | 2.0 | 1.5–3.0 | Brute never closes to attack range | Brute too fast to parry-react |
|
||
| `MoveSpeed` (Stalker) | 7.0 | 5.0–9.0 | Rush not threatening, easy to ignore | Rush unreactable, Wolf counter unreliable |
|
||
| `DetectionRange` (global across types) | 10–14 | 8–18 | Enemies don't aggro until player is on top of them | Snipes from offscreen, feels unfair |
|
||
| `AttackRange` vs `DetectionRange` | Attack ≤ Detection | — | Enemy can attack but can't detect (invalid config) | — |
|
||
| `AttackWindup` (Swarm) | 0.2s | 0.15–0.3s | Swarm hits with no warning | Swarm too easy to react to, no threat |
|
||
| `AttackWindup` (Brute) | 0.5s | 0.35–0.7s | Too fast to parry-react (violates counter design) | Too slow — parry trivial, Brute harmless |
|
||
| `AttackWindup` (Stalker) | 0.15s | 0.10–0.25s | No visual telegraph, unreactable | Rush feels sluggish, loses identity |
|
||
| `CooldownDuration` (Swarm) | 0.6s | 0.3–1.0s | Swarm attacks continuously — no breathing room | Swarm feels passive, no density pressure |
|
||
| `CooldownDuration` (Brute) | 1.2s | 0.8–2.0s | Brute chains attacks, no parry window between | Brute stands idle too long, not a threat |
|
||
| `CooldownDuration` (Stalker) | 0.8s | 0.4–1.2s | Stalker endlessly rushes, can't counter | Free kill window too generous |
|
||
| `MaxHealth` (Swarm) | 12 | 8–20 | Dies before player can react — no density | Takes too many hits, Mist AOE feels weak |
|
||
| `MaxHealth` (Brute) | 60 | 40–100 | Wolf burst one-shots, parry irrelevant | Damage sponge — tedious, not tactical |
|
||
| `MaxHealth` (Stalker) | 30 | 20–50 | Wolf one-shots, no "counter" satisfaction | Stalker survives too many rushes |
|
||
| `AttackBaseDamage` (Swarm) | 6 | 3–10 | Tickles — player ignores Swarm entirely | Swarm more threatening than Brute (per-hit) |
|
||
| `AttackBaseDamage` (Brute) | 25 | 18–40 | Parry not worth the risk (low reward) | One-shots player — parry mandatory or die |
|
||
| `AttackBaseDamage` (Stalker) | 18 | 12–28 | Rush not threatening enough to switch for | Stalker rush kills from full HP |
|
||
| `Weight` (Brute) | 8.0 | 5.0–10.0 | Brute gets knocked around, parry positioning trivial | Knockback zero — Brute feels glued to floor |
|
||
|
||
**Key tuning groups** (changing one requires checking the other):
|
||
|
||
- **Swarm trio**: `MaxHealth` + `AttackBaseDamage` + `MoveSpeed` — too high on all three and Swarm becomes the only enemy that matters
|
||
- **Brute parry feel**: `AttackWindup` + `AttackBaseDamage` — windup must be long enough to react, damage high enough to make parry worth the risk
|
||
- **Stalker rush feel**: `MoveSpeed` + `AttackWindup` + `AttackRange` — the rush duration (AttackRange / MoveSpeed) must be long enough for the player to register "Stalker incoming" and react
|
||
|
||
## Visual/Audio Requirements
|
||
|
||
Combat is a visual system category — Visual/Audio is REQUIRED.
|
||
|
||
**Enemy Visual Identity (per art bible principles):**
|
||
|
||
| Enemy Type | Geometric Shape | Color | Size Reference |
|
||
|------------|----------------|-------|----------------|
|
||
| **Swarm** | Small tetrahedron (pyramid) | Amber/orange | ~0.5 units diameter |
|
||
| **Brute** | Large cube/rectangular prism | Red/crimson | ~1.5 units width |
|
||
| **Stalker** | Elongated wedge (arrow-like) | Violet/magenta | ~1.0 units length |
|
||
|
||
**State Visual Telegraphing:**
|
||
|
||
| State | Visual |
|
||
|-------|--------|
|
||
| **Idle** | Static geometry, subtle idle bob |
|
||
| **Chase** | Lean toward player, slight forward tilt |
|
||
| **Attack Windup** | Enemy geometry flashes in its type-specific color + expands slightly (scale pulse at windup start). **This is the single most important visual cue** — players must be able to read windup starts through dense combat. Per-type windup VFX: Swarm = amber/orange glow burst; Brute = red/crimson flash; Stalker = violet/magenta streak. Color-coded windups make density-combat readable at a glance. |
|
||
| **Attack Active** | Attack shape preview rendered semi-transparent (same system as player attack preview, Combat Logic VFX) |
|
||
| **Cooldown** | Enemy geometry briefly dims/shrinks |
|
||
| **Dead** | Geometry shatters into fragments matching enemy color. Fragments persist briefly then fade. |
|
||
|
||
**VFX Events (via L1 VFX Spawner):**
|
||
|
||
| Event | VFX |
|
||
|-------|-----|
|
||
| `OnEnemyAttackWindup` | Flash + scale pulse at enemy position |
|
||
| `OnEnemyStateChanged` (to Chase) | Subtle motion trail activation |
|
||
| `OnEnemyDied` | Geometric shatter — fragment count scales with enemy size (Swarm=4, Brute=12, Stalker=8) |
|
||
|
||
**Audio Events (via L1 Audio Player):**
|
||
|
||
| Event | Audio |
|
||
|-------|-------|
|
||
| `OnEnemyAttackWindup` (Swarm) | High-pitched collective chirp |
|
||
| `OnEnemyAttackWindup` (Brute) | Deep heavy whoosh + bass |
|
||
| `OnEnemyAttackWindup` (Stalker) | Sharp tearing sound |
|
||
| `OnEnemyHit` | Impact SFX varying by enemy material (light clink for Swarm, heavy thud for Brute, sharp crack for Stalker) |
|
||
| `OnEnemyDied` | Shatter sound — pitch varies by enemy size |
|
||
|
||
**Audio prioritization at density**: At high enemy counts, simultaneous audio events can exceed the listener's perceptual capacity. L1 Audio Player must enforce:
|
||
- **Max simultaneous SFX**: 16 instances per frame (hard cap)
|
||
- **Priority order**: Brute windup > Stalker windup > player damage received > Swarm windup > crit hit > enemy death > normal hit
|
||
- **Distance falloff**: Audio from enemies within 5 units of player always plays; distant enemies (>15 units) are deprioritized
|
||
- **Instance merging**: For 3+ identical enemy events in the same frame (e.g., 10 Swarm deaths from Mist AOE), merge into a single composite sound with volume scaled by count (logarithmic, max 3x base volume)
|
||
|
||
**Art Bible Alignment**: Principle 1 (Color is Identity — enemy color signals type), Principle 2 (Silhouette is Information — distinct geometric shapes per type for readability at density), Principle 3 (Particles are Feedback — state changes communicated through geometric visual events, not complex animations).
|
||
|
||
## UI Requirements
|
||
|
||
Enemy AI Logic itself has minimal UI requirements — most enemy-related UI is owned by UI/HUD:
|
||
|
||
| Element | Owner | Notes |
|
||
|---------|-------|-------|
|
||
| Enemy health bars | UI/HUD | Positioned above enemies. Enemy AI provides currentHealth/maxHealth via public state for HUD to read. |
|
||
| Attack windup indicator | UI/HUD (or VFX) | Center-screen directional flash when an enemy within 10 units enters attack windup. **Gated**: minimum 0.5s cooldown between flashes — prevents strobe effect at high density. Flash color matches enemy type (amber=Swarm, red=Brute, violet=Stalker) for at-a-glance identification. |
|
||
| Enemy type icon | UI/HUD | Optional — small icon above enemy indicating encouraged form. Provisional plan: show on first encounter per enemy type per session, then hide. Definitive behavior deferred to playtest. |
|
||
|
||
## Acceptance Criteria
|
||
|
||
| # | GIVEN | WHEN | THEN |
|
||
|---|-------|------|------|
|
||
| 1 | Swarm enemy (TypeId=1) at position (0,0,0). Player at (8,0,0) — within DetectionRange(12.0). Enemy in Idle state. | `ComputeAction(enemy, player)` called | Enemy transitions to Chase. AICommand type = MoveTo, target = player position. |
|
||
| 2 | Swarm enemy in Chase at (1.5,0,0). Player at (0,0,0) — within AttackRange(1.5). | `ComputeAction(enemy, player)` called | Enemy transitions to Attack. AICommand type = Attack, AttackData populated with Circle(r=0.8), BaseDamage=6. |
|
||
| 3 | Brute enemy in Attack state. 0.5s elapses since Attack start (Windup complete). | Attack active frame occurs | AttackData produced with Circle(r=1.2), BaseDamage=25. Ready for Combat Logic injection. |
|
||
| 4 | Brute enemy in Attack state. Cooldown timer not started. | Attack animation completes | Enemy transitions to Cooldown. Cooldown timer starts (1.2s). |
|
||
| 5 | Brute enemy in Cooldown. 1.2s elapsed. Player at (5,0,0) — within DetectionRange(10.0) + 20% margin(12.0). | `ComputeAction(enemy, player)` called | Enemy transitions to Chase. |
|
||
| 6 | Enemy in Cooldown. Cooldown expired. Player at (13,0,0) — beyond DetectionRange(10.0) + 20% margin(12.0). | `ComputeAction(enemy, player)` called | Enemy transitions to Idle. AICommand type = Idle. |
|
||
| 7 | Enemy Health=20. `DamageResult { FinalDamage=15, KnockbackDistance=2.0, attackDirection=(1,0,0) }`. | `ApplyDamage(result)` called | newHealth=5, IsDead=false. Position shifts +2.0 on X. `OnEnemyHit` fires. `OnEnemyDied` does NOT fire. |
|
||
| 8 | Enemy Health=10. `DamageResult { FinalDamage=15, IsKillingBlow=true }`. | `ApplyDamage(result)` called | newHealth=0 (clamped), IsDead=true. `OnEnemyDied` fires with enemyId, enemyType, killerForm. |
|
||
| 9 | Enemy IsDead=true. Another `DamageResult` arrives. | `ApplyDamage(result)` called | Call ignored. No state change. No events. Health stays at 0. |
|
||
| 10 | deltaTime=0.0. Multiple enemies in various states. | `ComputeAction` loop runs | All enemies skipped. No state transitions, no movement, no timer advances. |
|
||
| 11 | Enemy spawned with player at (3,0,0) — within DetectionRange. | Enemy initialized | Enemy immediately enters Chase (skips Idle). Aggro flag set to true. |
|
||
| 12 | Brute in Attack Windup (0.3s into 0.5s windup). Player executes successful parry (Combat Logic emits `OnParrySuccess` for this enemy). | `OnParrySuccess` event received | Enemy attack nullified. Enemy enters stagger (0.45s stun). After stagger, force transition to Cooldown. |
|
||
| 13a | 200 enemies active, all in various states. deltaTime=0.016. | `ComputeAction` loop runs for all 200 | All 200 enemies processed correctly — each transitions/persists per its state rules. No crashes, no skipped enemies. |
|
||
| 13b | [Benchmark only — separate test suite] 200 enemies active. | 1000-frame benchmark run | Average <0.2ms per frame. No per-frame allocation (verified via GC.GetAllocatedBytesForCurrentThread). |
|
||
| 14 | Stalker (TypeId=3) in Chase at (6,0,0). Player at (0,0,0) — within AttackRange(6.0). | `ComputeAction(enemy, player)` called | Enemy transitions to Attack with Rect(w=1.5, l=6.0), BaseDamage=18, AttackWindup=0.15s. |
|
||
| 15 | Swarm Health=12 (full). Player Mist attack Circle(r=3.8, BaseDamage=8). Swarm at (1,0,0) — within circle. | Combat Logic resolves attack → `ApplyDamage(DamageResult { FinalDamage=8 })` | newHealth=4. Not dead. Second Mist hit needed to kill. |
|
||
| 16 | Enemy in Attack state. Player killed by another enemy in same frame. | Enemy's `ComputeAction` called with player.IsDead=true | Enemy transitions to Cooldown (if attacking) or Idle (if in Chase/Cooldown). Chase-state enemies force-transition to Idle on player.IsDead. No further attacks. |
|
||
| 17 | Swarm `AttackBaseDamage=6`, non-crit. | DamageResult arrives at Enemy AI | Enemy AI applies exactly 6 damage (no resistance modification). newHealth = currentHealth - 6. |
|
||
| 18 | Brute Weight=8.0. `KnockbackDistance=5.0` (from Combat Logic, already weight-factored). | `ApplyDamage(result)` called | Enemy position shifts by exactly 5.0 along attack direction. No additional weight calculation in Enemy AI — Combat Logic's value is authoritative. |
|
||
| 19 | Enemy in Chase at position equal to player position (distance=0). deltaTime=0.016. | `ComputeAction(enemy, player)` called | Enemy skips movement (zero direction guard). No NaN position. enemyPos unchanged. |
|
||
| 20 | Stalker in Attack, AttackWindup=0.15s. deltaTime=0.10s (frame spike). | `ComputeAction(enemy, player)` with clamped dt | Effective dt = min(0.10, 0.05) = 0.05s. Windup timer advances by 0.05s (not 0.10s). Windup NOT skipped — transitions to active frame on next Update call. |
|
||
| 21 | Enemy in Cooldown. Cooldown timer has 0.1s remaining. Player at (5,0,0) — within DetectionRange + margin. deltaTime=0.016. | `ComputeAction(enemy, player)` called | Cooldown timer = 0.084s (0.1 - 0.016). Stays in Cooldown. Does NOT prematurely transition to Chase. |
|
||
| 22 | Enemy in Chase state. Player position outside DetectionRange + 20% margin for 3+ consecutive seconds. | Sustained out-of-range condition | Enemy force-transitions to Idle (Chase timeout). Prevents permanent Chase when player is unreachable. |
|
||
| 23 | Enemy in Cooldown, player outside DetectionRange + 20% margin, timer expires. | `ComputeAction(enemy, player)` called | Enemy transitions to Idle (NOT Chase). Diagram-corrected transition: out-of-range post-Cooldown = Idle. |
|
||
| 24 | Enemy AI produces AttackData for attacking enemy. | AttackData fields verified | Source=Enemy, SourceForm=None, IsParry=false, CritChanceStat=0.0, CritMultiplier=1.0, AttackGroupId=0, InnerCritRadius=0.0. All [固定] fields correctly set. |
|
||
| 25 | Enemy in Chase. FacingAngle updated toward player. | `ComputeAction(enemy, player)` called | EnemyState.FacingAngle equals angle from enemy position to player position (in degrees, 0° = +X). Verified within ±1° tolerance. |
|
||
| 26 | Enemy spawned at player position (distance=0, player already in DetectionRange). | Enemy initialized | Enemy skips Idle, immediately enters Chase. Aggro flag set to true. No wake-up animation. |
|
||
| 27 | Enemy with AttackWindup=0 (degenerate config, logged as warning). | `ComputeAction(enemy, player)` in Attack state | Attack fires immediately on first Attack frame (windup skipped). Cooldown starts after attack active frame. Minimum floor enforced: AttackWindup ≥ 0.01s (one frame at 60fps). |
|
||
|
||
## Open Questions
|
||
|
||
- **Wolf vs Stalker clash** (resolved): Wolf Rect intersecting Stalker rush Rect triggers a special "clash" outcome — Stalker attack nullified, Stalker staggered for 0.45s, Wolf takes no damage from that attack. Implemented in Combat Logic as a detected Rect-Rect intersection in the parry phase.
|
||
- **Swarm per-frame damage cap** (resolved): Provisional cap of 50 damage/frame from Swarm hits. Prevents instant death from 20+ simultaneous Swarm attacks. Prototype validation needed.
|
||
- **Parry stagger (0.45s)** (resolved): Synced with Combat Logic GDD. Prototype validation needed to confirm riposte timing feel.
|
||
- Enemy-enemy collision: Deferred to vertical slice. Reserve a config flag (`hasCollision`) in EnemyTypeConfig for future minimum-separation repulsion.
|
||
- Detection range visualization: Should players see enemy detection ranges (e.g., debug overlay, or subtle ground ring)? Related to "Battlefield is Information" pillar.
|
||
- Attack pattern variety: Each type currently has exactly one attack pattern — **known MVP scope limitation** (fought ~6 times across a 7-wave level). Post-MVP multi-pattern expansion planned for vertical slice. Should multiple patterns per type be reserved for Boss AI Logic only?
|
||
- Should enemy attacks knock back the player? Currently undefined — enemy attacks deal damage but player knockback from enemy hits is not specified.
|
||
- Stalker Rect orientation: ADR-003 Rect is center-based. Is the Stalker's Rect(l=6.0) centered on the enemy position (extends 3.0 forward + 3.0 backward), or placed entirely forward from the origin? If centered, effective forward range is ~3.0, not 6.0. This needs clarification before Stalker implementation.
|
||
- Blood Energy on player death + wave restart: Player respawns with 0 energy but wave restarts with full enemy composition. Is the initial 0-energy state safe, or should the player receive a small starting energy grant on respawn? Defer to Death/Respawn Rules GDD.
|