38 KiB
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 在生成敌人时调用。此方法:
- 创建新的
EnemyState实例,从EnemyTypeConfig初始化 Position、FacingAngle、HitShape、Health、Weight - 将实例添加到 Enemy AI 的内部
EnemyState[]数组(CombatCoordinator 每帧读取) - 返回
enemyId(在 Enemy AI 内部单调递增分配) - 如果玩家已在 DetectionRange 内,直接进入 Chase 状态;否则进入 Idle
- 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
ComputeActionfor 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
ComputeActioncall 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
OnParrySuccessevent → 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.