# 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`,其中 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.