Vampire-Act-Base/design/gdd/enemy-ai-logic.md

38 KiB
Raw Blame History

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 集群包围迫使切雾形 AOEBrute 高伤害单次重击创造人形弹反窗口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 Recovery0.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 覆盖已接近的 Stalker4 次命中击杀,在 Stalker 冷却期间完成。Human 弹反理论上可行0.15s 窗口捕捉 0.15s 前摇但时机极紧——这是高阶技巧。Stalker 前摇0.15s)快于人类视觉反应时间(~200-250ms因此玩家无法纯粹靠反应弹反 Stalker——必须通过预判和站位来应对。"阅读 Stalker 行为"的方式是观察其低伏冲刺姿态和 violet streak VFX而非在起手式后才开始反应。

Wolf vs Stalker 对撞设计:当 Wolf Rect 与 Stalker 冲刺 Rect 相交时发生特殊"对撞"结果Stalker 攻击被 nullifiedWolf 赢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。CombatCoordinatorL0将此 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.014.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.07.0 Per EnemyTypeConfig
Delta time deltaTime float 00.033s Frame delta (typically ~0.016s at 60fps)

Output Range: 0.0320.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.56.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 0MaxHealth Pre-hit health
Final damage finalDamage float 0150 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 015 From DamageResult.KnockbackDistance

Output Range: 015 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.06.0 Swarm never reaches player, no pressure Swarm is unkitable, Mist form mandatory
MoveSpeed (Brute) 2.0 1.53.0 Brute never closes to attack range Brute too fast to parry-react
MoveSpeed (Stalker) 7.0 5.09.0 Rush not threatening, easy to ignore Rush unreactable, Wolf counter unreliable
DetectionRange (global across types) 1014 818 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.150.3s Swarm hits with no warning Swarm too easy to react to, no threat
AttackWindup (Brute) 0.5s 0.350.7s Too fast to parry-react (violates counter design) Too slow — parry trivial, Brute harmless
AttackWindup (Stalker) 0.15s 0.100.25s No visual telegraph, unreactable Rush feels sluggish, loses identity
CooldownDuration (Swarm) 0.6s 0.31.0s Swarm attacks continuously — no breathing room Swarm feels passive, no density pressure
CooldownDuration (Brute) 1.2s 0.82.0s Brute chains attacks, no parry window between Brute stands idle too long, not a threat
CooldownDuration (Stalker) 0.8s 0.41.2s Stalker endlessly rushes, can't counter Free kill window too generous
MaxHealth (Swarm) 12 820 Dies before player can react — no density Takes too many hits, Mist AOE feels weak
MaxHealth (Brute) 60 40100 Wolf burst one-shots, parry irrelevant Damage sponge — tedious, not tactical
MaxHealth (Stalker) 30 2050 Wolf one-shots, no "counter" satisfaction Stalker survives too many rushes
AttackBaseDamage (Swarm) 6 310 Tickles — player ignores Swarm entirely Swarm more threatening than Brute (per-hit)
AttackBaseDamage (Brute) 25 1840 Parry not worth the risk (low reward) One-shots player — parry mandatory or die
AttackBaseDamage (Stalker) 18 1228 Rush not threatening enough to switch for Stalker rush kills from full HP
Weight (Brute) 8.0 5.010.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.